diff --git a/docs/INSTALLING.md b/docs/INSTALLING.md index 3f2fbc31..db230270 100644 --- a/docs/INSTALLING.md +++ b/docs/INSTALLING.md @@ -19,6 +19,7 @@ Complete setup guide for Gas Town multi-agent orchestrator. | **tmux** | 3.0+ | `tmux -V` | See below | | **Claude Code** (default) | latest | `claude --version` | See [claude.ai/claude-code](https://claude.ai/claude-code) | | **Codex CLI** (optional) | latest | `codex --version` | See [developers.openai.com/codex/cli](https://developers.openai.com/codex/cli) | +| **OpenCode CLI** (optional) | latest | `opencode --version` | See [opencode.ai](https://opencode.ai) | ## Installing Prerequisites diff --git a/internal/cmd/crew_at.go b/internal/cmd/crew_at.go index 8b4e34f3..a19c4346 100644 --- a/internal/cmd/crew_at.go +++ b/internal/cmd/crew_at.go @@ -7,6 +7,7 @@ import ( "github.com/steveyegge/gastown/internal/config" "github.com/steveyegge/gastown/internal/constants" "github.com/steveyegge/gastown/internal/crew" + "github.com/steveyegge/gastown/internal/runtime" "github.com/steveyegge/gastown/internal/style" "github.com/steveyegge/gastown/internal/tmux" "github.com/steveyegge/gastown/internal/workspace" @@ -88,6 +89,7 @@ func runCrewAt(cmd *cobra.Command, args []string) error { } runtimeConfig := config.LoadRuntimeConfig(r.Path) + _ = runtime.EnsureSettingsForRole(worker.ClonePath, "crew", runtimeConfig) // Check if session exists t := tmux.NewTmux() diff --git a/internal/config/types.go b/internal/config/types.go index 7886265b..20c431a8 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -269,7 +269,7 @@ type RuntimeSessionConfig struct { // RuntimeHooksConfig configures runtime hook installation. type RuntimeHooksConfig struct { - // Provider controls which hook templates to install: "claude" or "none". + // Provider controls which hook templates to install: "claude", "opencode", or "none". Provider string `json:"provider,omitempty"` // Dir is the settings directory (e.g., ".claude"). @@ -435,6 +435,8 @@ func defaultRuntimeCommand(provider string) string { switch provider { case "codex": return "codex" + case "opencode": + return "opencode" case "generic": return "" default: @@ -455,6 +457,8 @@ func defaultPromptMode(provider string) string { switch provider { case "codex": return "none" + case "opencode": + return "none" default: return "arg" } @@ -475,24 +479,36 @@ func defaultConfigDirEnv(provider string) string { } func defaultHooksProvider(provider string) string { - if provider == "claude" { + switch provider { + case "claude": return "claude" + case "opencode": + return "opencode" + default: + return "none" } - return "none" } func defaultHooksDir(provider string) string { - if provider == "claude" { + switch provider { + case "claude": return ".claude" + case "opencode": + return ".opencode/plugin" + default: + return "" } - return "" } func defaultHooksFile(provider string) string { - if provider == "claude" { + switch provider { + case "claude": return "settings.json" + case "opencode": + return "gastown.js" + default: + return "" } - return "" } func defaultProcessNames(provider, command string) []string { @@ -526,6 +542,9 @@ func defaultInstructionsFile(provider string) string { if provider == "codex" { return "AGENTS.md" } + if provider == "opencode" { + return "AGENTS.md" + } return "CLAUDE.md" } diff --git a/internal/opencode/plugin.go b/internal/opencode/plugin.go new file mode 100644 index 00000000..de03d9d9 --- /dev/null +++ b/internal/opencode/plugin.go @@ -0,0 +1,40 @@ +// Package opencode provides OpenCode plugin management. +package opencode + +import ( + "embed" + "fmt" + "os" + "path/filepath" +) + +//go:embed plugin/gastown.js +var pluginFS embed.FS + +// EnsurePluginAt ensures the Gas Town OpenCode plugin exists. +// If the file already exists, it's left unchanged. +func EnsurePluginAt(workDir, pluginDir, pluginFile string) error { + if pluginDir == "" || pluginFile == "" { + return nil + } + + pluginPath := filepath.Join(workDir, pluginDir, pluginFile) + if _, err := os.Stat(pluginPath); err == nil { + return nil + } + + if err := os.MkdirAll(filepath.Dir(pluginPath), 0755); err != nil { + return fmt.Errorf("creating plugin directory: %w", err) + } + + content, err := pluginFS.ReadFile("plugin/gastown.js") + if err != nil { + return fmt.Errorf("reading plugin template: %w", err) + } + + if err := os.WriteFile(pluginPath, content, 0644); err != nil { + return fmt.Errorf("writing plugin: %w", err) + } + + return nil +} diff --git a/internal/opencode/plugin/gastown.js b/internal/opencode/plugin/gastown.js new file mode 100644 index 00000000..a44dc942 --- /dev/null +++ b/internal/opencode/plugin/gastown.js @@ -0,0 +1,32 @@ +// Gas Town OpenCode plugin: hooks SessionStart/Compaction via events. +export const GasTown = async ({ $, directory }) => { + const role = (process.env.GT_ROLE || "").toLowerCase(); + const autonomousRoles = new Set(["polecat", "witness", "refinery", "deacon"]); + let didInit = false; + + const run = async (cmd) => { + try { + await $`/bin/sh -lc ${cmd}`.cwd(directory); + } catch (err) { + console.error(`[gastown] ${cmd} failed`, err?.message || err); + } + }; + + const onSessionCreated = async () => { + if (didInit) return; + didInit = true; + await run("gt prime"); + if (autonomousRoles.has(role)) { + await run("gt mail check --inject"); + } + await run("gt nudge deacon session-started"); + }; + + return { + event: async ({ event }) => { + if (event?.type === "session.created") { + await onSessionCreated(); + } + }, + }; +}; diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index b28c4094..e26be5f0 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -8,6 +8,7 @@ import ( "github.com/steveyegge/gastown/internal/claude" "github.com/steveyegge/gastown/internal/config" + "github.com/steveyegge/gastown/internal/opencode" "github.com/steveyegge/gastown/internal/tmux" ) @@ -24,6 +25,8 @@ func EnsureSettingsForRole(workDir, role string, rc *config.RuntimeConfig) error switch rc.Hooks.Provider { case "claude": return claude.EnsureSettingsForRoleAt(workDir, role, rc.Hooks.Dir, rc.Hooks.SettingsFile) + case "opencode": + return opencode.EnsurePluginAt(workDir, rc.Hooks.Dir, rc.Hooks.SettingsFile) default: return nil } @@ -56,7 +59,7 @@ func StartupFallbackCommands(role string, rc *config.RuntimeConfig) []string { if rc == nil { rc = config.DefaultRuntimeConfig() } - if rc.Hooks != nil && rc.Hooks.Provider == "claude" { + if rc.Hooks != nil && rc.Hooks.Provider != "" && rc.Hooks.Provider != "none" { return nil }