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 bb04eecae..71193e419 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)"