From c26b86047c98838135acd1f0a971f5a76e352836 Mon Sep 17 00:00:00 2001 From: hello hello Date: Wed, 11 Feb 2026 18:53:40 +0000 Subject: [PATCH] Add OpenCodeAgent implementation for Entire CLI This commit introduces the OpenCodeAgent, which implements the Agent interface for the Entire CLI. The new agent supports detection of OpenCode presence in repositories, manages session data, and provides methods for reading and writing session information. It also includes configuration handling and session ID transformations, laying the groundwork for future integration with OpenCode's plugin system. --- cmd/entire/cli/agent/opencode/opencode.go | 235 ++++++++++++++++++++++ cmd/entire/cli/agent/registry.go | 2 + cmd/entire/cli/agent/registry_test.go | 3 + cmd/entire/cli/config.go | 2 + cmd/entire/cli/hooks_cmd.go | 1 + cmd/entire/cli/setup.go | 28 +-- 6 files changed, 258 insertions(+), 13 deletions(-) create mode 100644 cmd/entire/cli/agent/opencode/opencode.go diff --git a/cmd/entire/cli/agent/opencode/opencode.go b/cmd/entire/cli/agent/opencode/opencode.go new file mode 100644 index 000000000..a99e3a076 --- /dev/null +++ b/cmd/entire/cli/agent/opencode/opencode.go @@ -0,0 +1,235 @@ +package opencode + +import ( + "errors" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/paths" + "github.com/entireio/cli/cmd/entire/cli/sessionid" +) + +//nolint:gochecknoinits // Agent self-registration is the intended pattern +func init() { + agent.Register(agent.AgentNameOpencode, NewOpenCodeAgent) +} + +// OpenCodeAgent implements the Agent interface for OpenCode. +// +//nolint:revive // OpenCodeAgent is clearer than Agent in this context +type OpenCodeAgent struct{} + +// NewOpenCodeAgent creates a new OpenCode agent instance. +func NewOpenCodeAgent() agent.Agent { + return &OpenCodeAgent{} +} + +// Name returns the agent registry key. +func (o *OpenCodeAgent) Name() agent.AgentName { + return agent.AgentNameOpencode +} + +// Type returns the agent type identifier. +func (o *OpenCodeAgent) Type() agent.AgentType { + return agent.AgentTypeOpencode +} + +// Description returns a human-readable description. +func (o *OpenCodeAgent) Description() string { + return "OpenCode - Open source AI coding agent" +} + +// DetectPresence checks if OpenCode is configured in the repository. +// +// OpenCode uses either a project config file (opencode.json) or a +// project-local configuration directory (.opencode/). We treat either +// as a signal that OpenCode is in use for this repository. +func (o *OpenCodeAgent) DetectPresence() (bool, error) { + repoRoot, err := paths.RepoRoot() + if err != nil { + // Not in a git repo, fall back to CWD-relative check + repoRoot = "." + } + + opencodeDir := filepath.Join(repoRoot, ".opencode") + if _, err := os.Stat(opencodeDir); err == nil { + return true, nil + } + + configFile := filepath.Join(repoRoot, "opencode.json") + if _, err := os.Stat(configFile); err == nil { + return true, nil + } + + return false, nil +} + +// GetHookConfigPath returns the path to OpenCode's primary config file. +// This is informational only; hooks are typically configured via plugins. +func (o *OpenCodeAgent) GetHookConfigPath() string { + return "opencode.json" +} + +// SupportsHooks reports whether the agent supports lifecycle hooks managed +// directly by Entire. OpenCode integrations are typically implemented via +// its plugin system invoking `entire hooks ...`, so we return false here +// to indicate that Entire does not install or manage those hooks itself. +func (o *OpenCodeAgent) SupportsHooks() bool { + return false +} + +// ParseHookInput parses hook callback input from stdin. +// +// Since OpenCode hooks are expected to be implemented via its plugin system +// and are not yet standardized for Entire, this returns a descriptive error +// if called. This keeps the implementation explicit and easy to extend once +// a concrete hook payload schema is agreed upon. +func (o *OpenCodeAgent) ParseHookInput(_ agent.HookType, _ io.Reader) (*agent.HookInput, error) { //nolint:ireturn // interface contract + return nil, errors.New("OpenCode hooks are not yet implemented in Entire") +} + +// GetSessionID extracts the session ID from hook input. +// For OpenCode this is currently a simple passthrough. +func (o *OpenCodeAgent) GetSessionID(input *agent.HookInput) string { + if input == nil { + return "" + } + return input.SessionID +} + +// TransformSessionID converts an OpenCode session ID to an Entire session ID. +// This is currently an identity mapping to match other modern agents. +func (o *OpenCodeAgent) TransformSessionID(agentSessionID string) string { + return agentSessionID +} + +// ExtractAgentSessionID extracts the OpenCode session ID from an Entire session ID. +// For backwards compatibility with legacy date-prefixed IDs, it strips the prefix +// if present, mirroring the behavior of other agents. +func (o *OpenCodeAgent) ExtractAgentSessionID(entireSessionID string) string { + return sessionid.ModelSessionID(entireSessionID) +} + +// ProtectedDirs returns directories that OpenCode uses for project-local config/state. +func (o *OpenCodeAgent) ProtectedDirs() []string { return []string{".opencode"} } + +// GetSessionDir returns the root directory where OpenCode stores session data. +// +// OpenCode uses an XDG-style data root, typically: +// ~/.local/share/opencode/storage/session//.json +// +// Because project IDs are internal to OpenCode, we return the session storage +// root and let ResolveSessionFile locate the concrete session file. +func (o *OpenCodeAgent) GetSessionDir(_ string) (string, error) { + dataDir := os.Getenv("XDG_DATA_HOME") + if dataDir == "" { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) + } + dataDir = filepath.Join(homeDir, ".local", "share") + } + + return filepath.Join(dataDir, "opencode", "storage", "session"), nil +} + +// ResolveSessionFile attempts to locate a concrete session file for a given +// OpenCode session ID. The expected layout is: +// +// //.json +// +// We scan one level of subdirectories looking for a matching session file and +// fall back to /.json if no match is found. This keeps +// the implementation robust across minor layout changes while avoiding deep +// recursive scans. +func (o *OpenCodeAgent) ResolveSessionFile(sessionDir, agentSessionID string) string { + if sessionDir == "" || agentSessionID == "" { + return "" + } + + entries, err := os.ReadDir(sessionDir) + if err == nil { + for _, entry := range entries { + if !entry.IsDir() { + continue + } + candidate := filepath.Join(sessionDir, entry.Name(), agentSessionID+".json") + if _, statErr := os.Stat(candidate); statErr == nil { + return candidate + } + } + } + + // Fallback: treat sessionDir as flat storage + return filepath.Join(sessionDir, agentSessionID+".json") +} + +// ReadSession reads a session from OpenCode's storage. +// +// At this stage, OpenCode's on-disk session format is treated as opaque. We +// read the raw bytes and store them in NativeData so higher-level features +// that do not rely on a specific transcript schema can still function. +// ModifiedFiles and token usage are left to be implemented once a stable +// transcript schema is finalized. +func (o *OpenCodeAgent) ReadSession(input *agent.HookInput) (*agent.AgentSession, error) { //nolint:ireturn // interface contract + if input == nil { + return nil, errors.New("hook input is nil") + } + if input.SessionRef == "" { + return nil, errors.New("session reference (transcript path) is required") + } + + data, err := os.ReadFile(input.SessionRef) + if err != nil { + return nil, fmt.Errorf("failed to read OpenCode session: %w", err) + } + + return &agent.AgentSession{ + SessionID: input.SessionID, + AgentName: o.Name(), + SessionRef: input.SessionRef, + NativeData: data, + }, nil +} + +// WriteSession writes a session back to OpenCode's storage. +// +// Since the session format is treated as opaque, this simply writes NativeData +// back to the provided SessionRef path. It is the caller's responsibility to +// ensure that the data is in a format OpenCode can understand (for example, +// by starting from a session that was originally produced by OpenCode). +func (o *OpenCodeAgent) WriteSession(session *agent.AgentSession) error { + if session == nil { + return errors.New("session is nil") + } + + if session.AgentName != "" && session.AgentName != o.Name() { + return fmt.Errorf("session belongs to agent %q, not %q", session.AgentName, o.Name()) + } + + if session.SessionRef == "" { + return errors.New("session reference (transcript path) is required") + } + + if len(session.NativeData) == 0 { + return errors.New("session has no native data to write") + } + + if err := os.WriteFile(session.SessionRef, session.NativeData, 0o600); err != nil { + return fmt.Errorf("failed to write OpenCode session: %w", err) + } + + return nil +} + +// FormatResumeCommand returns the command to resume an OpenCode session. +func (o *OpenCodeAgent) FormatResumeCommand(sessionID string) string { + if sessionID == "" { + return "opencode" + } + return "opencode --session " + sessionID +} + diff --git a/cmd/entire/cli/agent/registry.go b/cmd/entire/cli/agent/registry.go index 5f3df9e02..254e86c28 100644 --- a/cmd/entire/cli/agent/registry.go +++ b/cmd/entire/cli/agent/registry.go @@ -80,12 +80,14 @@ type AgentType string const ( AgentNameClaudeCode AgentName = "claude-code" AgentNameGemini AgentName = "gemini" + AgentNameOpencode AgentName = "opencode" ) // Agent type constants (type identifiers stored in metadata/trailers) const ( AgentTypeClaudeCode AgentType = "Claude Code" AgentTypeGemini AgentType = "Gemini CLI" + AgentTypeOpencode AgentType = "OpenCode" AgentTypeUnknown AgentType = "Agent" // Fallback for backwards compatibility ) diff --git a/cmd/entire/cli/agent/registry_test.go b/cmd/entire/cli/agent/registry_test.go index 1e6eb7f5f..74fcbdc12 100644 --- a/cmd/entire/cli/agent/registry_test.go +++ b/cmd/entire/cli/agent/registry_test.go @@ -138,6 +138,9 @@ func TestAgentNameConstants(t *testing.T) { if AgentNameGemini != "gemini" { t.Errorf("expected AgentNameGemini %q, got %q", "gemini", AgentNameGemini) } + if AgentNameOpencode != "opencode" { + t.Errorf("expected AgentNameOpencode %q, got %q", "opencode", AgentNameOpencode) + } } func TestDefaultAgentName(t *testing.T) { diff --git a/cmd/entire/cli/config.go b/cmd/entire/cli/config.go index 48246c5be..c0e54abd2 100644 --- a/cmd/entire/cli/config.go +++ b/cmd/entire/cli/config.go @@ -13,6 +13,8 @@ import ( // Import claudecode to register the agent _ "github.com/entireio/cli/cmd/entire/cli/agent/claudecode" + // Import opencode to register the agent + _ "github.com/entireio/cli/cmd/entire/cli/agent/opencode" ) // Package-level aliases to avoid shadowing the settings package with local variables named "settings". diff --git a/cmd/entire/cli/hooks_cmd.go b/cmd/entire/cli/hooks_cmd.go index d12922523..918a8848d 100644 --- a/cmd/entire/cli/hooks_cmd.go +++ b/cmd/entire/cli/hooks_cmd.go @@ -5,6 +5,7 @@ import ( // Import agents to ensure they are registered before we iterate _ "github.com/entireio/cli/cmd/entire/cli/agent/claudecode" _ "github.com/entireio/cli/cmd/entire/cli/agent/geminicli" + _ "github.com/entireio/cli/cmd/entire/cli/agent/opencode" "github.com/spf13/cobra" ) diff --git a/cmd/entire/cli/setup.go b/cmd/entire/cli/setup.go index 3313ac3c0..e0a794fd9 100644 --- a/cmd/entire/cli/setup.go +++ b/cmd/entire/cli/setup.go @@ -547,18 +547,19 @@ func printWrongAgentError(w io.Writer, name string) { // If strategyName is provided, it sets the strategy; otherwise uses default. func setupAgentHooksNonInteractive(w io.Writer, ag agent.Agent, strategyName string, localDev, forceHooks, skipPushSessions, telemetry bool) error { agentName := ag.Name() - // Check if agent supports hooks - hookAgent, ok := ag.(agent.HookSupport) - if !ok { - return fmt.Errorf("agent %s does not support hooks", agentName) - } - fmt.Fprintf(w, "Agent: %s\n\n", ag.Type()) - // Install agent hooks (agent hooks don't depend on settings) - installedHooks, err := hookAgent.InstallHooks(localDev, forceHooks) - if err != nil { - return fmt.Errorf("failed to install hooks for %s: %w", agentName, err) + installedHooks := 0 + + // If the agent supports hooks, let it install/manage them. Otherwise, we + // still proceed with Entire setup so non-hook-based integrations (for + // example, plugins that call `entire hooks ...` directly) can be used. + if hookAgent, ok := ag.(agent.HookSupport); ok { + var err error + installedHooks, err = hookAgent.InstallHooks(localDev, forceHooks) + if err != nil { + return fmt.Errorf("failed to install hooks for %s: %w", agentName, err) + } } // Setup .entire directory @@ -615,13 +616,14 @@ func setupAgentHooksNonInteractive(w io.Writer, ag agent.Agent, strategyName str return fmt.Errorf("failed to install git hooks: %w", err) } - if installedHooks == 0 { - msg := fmt.Sprintf("Hooks for %s already installed", ag.Description()) + switch { + case installedHooks == 0: + msg := fmt.Sprintf("Hooks for %s already installed or managed externally", ag.Description()) if agentName == agent.AgentNameGemini { msg += " (Preview)" } fmt.Fprintf(w, "%s\n", msg) - } else { + default: msg := fmt.Sprintf("Installed %d hooks for %s", installedHooks, ag.Description()) if agentName == agent.AgentNameGemini { msg += " (Preview)"