Prepare automated releases

This commit is contained in:
Emanuel Casco 2026-05-05 10:13:15 +02:00
parent 3d43448442
commit 50a2d887fd
10 changed files with 850 additions and 2 deletions

7
.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
bin/
dist/
*.exe
*.pid
.env
.tmp/
oc-go-cc

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Emanuel Casco
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

22
Makefile Normal file
View file

@ -0,0 +1,22 @@
.PHONY: build run test clean install release
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
build:
go build -ldflags "-X main.version=$(VERSION)" -o bin/ocgo ./cmd/ocgo
run:
go run ./cmd/ocgo
test:
go test ./...
clean:
rm -rf bin
install: build
install -m 0755 bin/ocgo $(HOME)/go/bin/ocgo
release:
@[ -n "$(TAG)" ] || (echo "Usage: make release TAG=v0.1.0" && exit 1)
./scripts/release.sh "$(TAG)"

View file

@ -226,10 +226,10 @@ gh auth login
Release a new version: Release a new version:
```bash ```bash
HOMEBREW_TAP_REPO=YOUR_GITHUB_USER/homebrew-tap make release TAG=v0.1.0 make release TAG=v0.1.0
``` ```
Optionally set `GITHUB_REPOSITORY=owner/repo` if the script cannot infer it from `origin`. By default, releases are published to `emanuelcasco/ocgo` and the Homebrew formula is pushed to `emanuelcasco/homebrew-tap`. You can override those with `GITHUB_REPOSITORY=owner/repo` and `HOMEBREW_TAP_REPO=owner/homebrew-tap`.
The script builds macOS/Linux `amd64` and `arm64` archives, uploads them to GitHub Releases, and commits `Formula/ocgo.rb` to the tap repo. The script builds macOS/Linux `amd64` and `arm64` archives, uploads them to GitHub Releases, and commits `Formula/ocgo.rb` to the tap repo.

9
cmd/ocgo/detach_unix.go Normal file
View file

@ -0,0 +1,9 @@
//go:build !windows
package main
import "syscall"
func detachedAttrs() *syscall.SysProcAttr {
return &syscall.SysProcAttr{Setsid: true}
}

View file

@ -0,0 +1,7 @@
//go:build windows
package main
import "syscall"
func detachedAttrs() *syscall.SysProcAttr { return nil }

601
cmd/ocgo/main.go Normal file
View file

@ -0,0 +1,601 @@
package main
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
"github.com/spf13/cobra"
)
const (
appName = "ocgo"
defaultHost = "127.0.0.1"
defaultPort = 3456
openAIURL = "https://opencode.ai/zen/go/v1/chat/completions"
)
var version = "dev"
type Config struct {
APIKey string `json:"api_key"`
Host string `json:"host"`
Port int `json:"port"`
}
type AnthropicRequest struct {
Model string `json:"model"`
MaxTokens int `json:"max_tokens"`
System json.RawMessage `json:"system,omitempty"`
Messages []AMessage `json:"messages"`
Stream bool `json:"stream,omitempty"`
Tools []ATool `json:"tools,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
}
type AMessage struct {
Role string `json:"role"`
Content json.RawMessage `json:"content"`
}
type ATool struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
InputSchema json.RawMessage `json:"input_schema,omitempty"`
}
type OAIRequest struct {
Model string `json:"model"`
Messages []OAIMessage `json:"messages"`
Stream bool `json:"stream,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
Tools []OAITool `json:"tools,omitempty"`
}
type OAIMessage struct {
Role string `json:"role"`
Content string `json:"content,omitempty"`
ToolCalls []OAIToolCall `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
}
type OAITool struct {
Type string `json:"type"`
Function OAIFunction `json:"function"`
}
type OAIFunction struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Parameters json.RawMessage `json:"parameters,omitempty"`
}
type OAIToolCall struct {
ID string `json:"id"`
Type string `json:"type"`
Function OAICallFunction `json:"function"`
}
type OAICallFunction struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
}
func main() {
root := &cobra.Command{Use: appName, Short: "Run Claude Code with OpenCode Go", Version: version}
root.AddCommand(setupCmd(), listCmd(), launchCmd(), serveCmd(), stopCmd(), statusCmd())
if err := root.Execute(); err != nil {
os.Exit(1)
}
}
func setupCmd() *cobra.Command {
var key string
cmd := &cobra.Command{
Use: "setup",
Short: "Save your OpenCode Go API key",
RunE: func(cmd *cobra.Command, args []string) error {
if strings.TrimSpace(key) == "" {
key = os.Getenv("OCGO_API_KEY")
}
if strings.TrimSpace(key) == "" {
fmt.Print("OpenCode Go API key: ")
line, err := bufio.NewReader(os.Stdin).ReadString('\n')
if err != nil && line == "" {
return err
}
key = line
}
cfg := Config{APIKey: strings.TrimSpace(key), Host: defaultHost, Port: defaultPort}
if cfg.APIKey == "" {
return errors.New("API key cannot be empty")
}
return saveConfig(cfg)
},
}
cmd.Flags().StringVar(&key, "api-key", "", "OpenCode Go API key")
return cmd
}
func listCmd() *cobra.Command {
return &cobra.Command{Use: "list", Aliases: []string{"ls", "models"}, Short: "List OpenCode Go models", Run: func(cmd *cobra.Command, args []string) {
fmt.Println("OpenCode Go models:")
for _, m := range []string{"glm-5.1", "glm-5", "kimi-k2.6", "kimi-k2.5", "mimo-v2.5-pro", "mimo-v2.5", "mimo-v2-pro", "mimo-v2-omni", "minimax-m2.7", "minimax-m2.5", "deepseek-v4-pro", "deepseek-v4-flash", "qwen3.6-plus", "qwen3.5-plus"} {
fmt.Printf(" %s\n", m)
}
}}
}
func launchCmd() *cobra.Command {
var model string
var yes bool
cmd := &cobra.Command{Use: "launch", Short: "Launch tools through ocgo"}
claude := &cobra.Command{Use: "claude [-- claude args...]", Short: "Launch Claude Code through OpenCode Go", Args: cobra.ArbitraryArgs, RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := loadConfig()
if err != nil {
return err
}
base := fmt.Sprintf("http://%s:%d", cfg.Host, cfg.Port)
serverCmd, err := startLaunchServer(base)
if err != nil {
return err
}
if serverCmd != nil {
defer stopManagedServer(serverCmd)
}
claudeArgs := append([]string{}, args...)
if yes {
claudeArgs = append([]string{"--dangerously-skip-permissions"}, claudeArgs...)
}
bin, err := exec.LookPath("claude")
if err != nil {
return fmt.Errorf("claude not found in PATH: %w", err)
}
c := exec.Command(bin, claudeArgs...)
c.Stdin, c.Stdout, c.Stderr = os.Stdin, os.Stdout, os.Stderr
c.Env = append(os.Environ(), "ANTHROPIC_BASE_URL="+base, "ANTHROPIC_AUTH_TOKEN=unused")
if model != "" {
c.Env = append(c.Env, "ANTHROPIC_MODEL="+model, "ANTHROPIC_SMALL_FAST_MODEL="+model)
}
return c.Run()
}}
claude.Flags().StringVar(&model, "model", "", "OpenCode Go model ID")
claude.Flags().BoolVar(&yes, "yes", false, "Allow Claude Code to skip permission prompts")
cmd.AddCommand(claude)
return cmd
}
func serveCmd() *cobra.Command {
var background bool
cmd := &cobra.Command{Use: "serve", Short: "Start local Anthropic-compatible proxy", RunE: func(cmd *cobra.Command, args []string) error {
if background {
return startBackground()
}
cfg, err := loadConfig()
if err != nil {
return err
}
return runServer(cfg)
}}
cmd.Flags().BoolVarP(&background, "background", "b", false, "Run proxy in the background")
return cmd
}
func stopCmd() *cobra.Command {
return &cobra.Command{Use: "stop", Short: "Stop background proxy", RunE: func(cmd *cobra.Command, args []string) error {
pid, err := readPID()
if err != nil {
cfg, cfgErr := loadConfig()
if cfgErr != nil {
return errors.New("proxy is not running")
}
pid, err = findListenerPID(cfg.Port)
if err != nil {
return errors.New("proxy is not running")
}
}
p, err := os.FindProcess(pid)
if err != nil {
return err
}
_ = os.Remove(pidFile())
if err := p.Kill(); err != nil {
return err
}
fmt.Printf("Stopped proxy process %d\n", pid)
return nil
}}
}
func statusCmd() *cobra.Command {
return &cobra.Command{Use: "status", Short: "Show proxy status", Run: func(cmd *cobra.Command, args []string) {
cfg, err := loadConfig()
if err != nil || !healthy(fmt.Sprintf("http://%s:%d", cfg.Host, cfg.Port)) {
fmt.Println("Proxy is not running")
return
}
if pid, err := readPID(); err == nil {
fmt.Printf("Proxy is running on %s:%d (PID %d)\n", cfg.Host, cfg.Port, pid)
return
}
fmt.Printf("Proxy is running on %s:%d (no ocgo PID file)\n", cfg.Host, cfg.Port)
}}
}
func runServer(cfg Config) error {
if err := os.MkdirAll(configDir(), 0755); err == nil {
_ = os.WriteFile(pidFile(), []byte(fmt.Sprint(os.Getpid())), 0644)
defer os.Remove(pidFile())
}
mux := http.NewServeMux()
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("ok\n")) })
mux.HandleFunc("/v1/messages/count_tokens", countTokens)
mux.HandleFunc("/v1/messages", func(w http.ResponseWriter, r *http.Request) { proxyMessages(w, r, cfg) })
addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port)
fmt.Printf("ocgo proxy listening on http://%s\n", addr)
return http.ListenAndServe(addr, mux)
}
func proxyMessages(w http.ResponseWriter, r *http.Request, cfg Config) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var ar AnthropicRequest
if err := json.NewDecoder(r.Body).Decode(&ar); err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
or := convertRequest(ar)
body, _ := json.Marshal(or)
req, err := http.NewRequestWithContext(r.Context(), http.MethodPost, openAIURL, bytes.NewReader(body))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
req.Header.Set("Authorization", "Bearer "+cfg.APIKey)
req.Header.Set("Content-Type", "application/json")
resp, err := (&http.Client{Timeout: 10 * time.Minute}).Do(req)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
w.WriteHeader(resp.StatusCode)
_, _ = io.Copy(w, resp.Body)
return
}
if ar.Stream {
streamAnthropic(w, resp.Body, or.Model)
return
}
writeAnthropicResponse(w, resp.Body, or.Model)
}
func convertRequest(ar AnthropicRequest) OAIRequest {
model := ar.Model
if model == "" || strings.HasPrefix(model, "claude-") {
model = "kimi-k2.6"
}
out := OAIRequest{Model: model, Stream: ar.Stream, MaxTokens: ar.MaxTokens, Temperature: ar.Temperature, TopP: ar.TopP}
if sys := systemText(ar.System); sys != "" {
out.Messages = append(out.Messages, OAIMessage{Role: "system", Content: sys})
}
for _, m := range ar.Messages {
out.Messages = append(out.Messages, contentToOpenAI(m)...)
}
for _, t := range ar.Tools {
out.Tools = append(out.Tools, OAITool{Type: "function", Function: OAIFunction{Name: t.Name, Description: t.Description, Parameters: t.InputSchema}})
}
return out
}
func contentToOpenAI(m AMessage) []OAIMessage {
var s string
if json.Unmarshal(m.Content, &s) == nil {
return []OAIMessage{{Role: m.Role, Content: s}}
}
var blocks []map[string]json.RawMessage
if json.Unmarshal(m.Content, &blocks) != nil {
return []OAIMessage{{Role: m.Role, Content: string(m.Content)}}
}
var text strings.Builder
var calls []OAIToolCall
var toolMsgs []OAIMessage
for _, b := range blocks {
var typ string
_ = json.Unmarshal(b["type"], &typ)
switch typ {
case "text":
var v string
_ = json.Unmarshal(b["text"], &v)
text.WriteString(v)
case "tool_use":
var id, name string
_ = json.Unmarshal(b["id"], &id)
_ = json.Unmarshal(b["name"], &name)
args := "{}"
if raw := b["input"]; len(raw) > 0 {
args = string(raw)
}
calls = append(calls, OAIToolCall{ID: id, Type: "function", Function: OAICallFunction{Name: name, Arguments: args}})
case "tool_result":
var id string
_ = json.Unmarshal(b["tool_use_id"], &id)
toolMsgs = append(toolMsgs, OAIMessage{Role: "tool", ToolCallID: id, Content: blockText(b["content"])})
}
}
if len(calls) > 0 {
return []OAIMessage{{Role: "assistant", Content: text.String(), ToolCalls: calls}}
}
if len(toolMsgs) > 0 {
return toolMsgs
}
return []OAIMessage{{Role: m.Role, Content: text.String()}}
}
func systemText(raw json.RawMessage) string {
if len(raw) == 0 {
return ""
}
var s string
if json.Unmarshal(raw, &s) == nil {
return s
}
return blockText(raw)
}
func blockText(raw json.RawMessage) string {
var s string
if json.Unmarshal(raw, &s) == nil {
return s
}
var blocks []map[string]json.RawMessage
if json.Unmarshal(raw, &blocks) != nil {
return string(raw)
}
var b strings.Builder
for _, x := range blocks {
var t string
if json.Unmarshal(x["text"], &t) == nil {
b.WriteString(t)
}
}
return b.String()
}
func streamAnthropic(w http.ResponseWriter, body io.Reader, model string) {
w.Header().Set("Content-Type", "text/event-stream")
flusher, _ := w.(http.Flusher)
fmt.Fprintf(w, "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"ocgo\",\"type\":\"message\",\"role\":\"assistant\",\"model\":%q,\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":0,\"output_tokens\":0}}}\n\n", model)
fmt.Fprint(w, "event: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n")
if flusher != nil {
flusher.Flush()
}
s := bufio.NewScanner(body)
for s.Scan() {
line := strings.TrimSpace(s.Text())
if !strings.HasPrefix(line, "data:") {
continue
}
data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
if data == "[DONE]" {
break
}
if delta := openAITextDelta([]byte(data)); delta != "" {
b, _ := json.Marshal(delta)
fmt.Fprintf(w, "event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":%s}}\n\n", b)
if flusher != nil {
flusher.Flush()
}
}
}
fmt.Fprint(w, "event: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\n")
fmt.Fprint(w, "event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null},\"usage\":{\"output_tokens\":0}}\n\n")
fmt.Fprint(w, "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n")
}
func openAITextDelta(data []byte) string {
var v struct {
Choices []struct {
Delta struct {
Content string `json:"content"`
} `json:"delta"`
} `json:"choices"`
}
_ = json.Unmarshal(data, &v)
if len(v.Choices) == 0 {
return ""
}
return v.Choices[0].Delta.Content
}
func writeAnthropicResponse(w http.ResponseWriter, body io.Reader, model string) {
var v struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
}
_ = json.NewDecoder(body).Decode(&v)
text := ""
if len(v.Choices) > 0 {
text = v.Choices[0].Message.Content
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"id": "ocgo", "type": "message", "role": "assistant", "model": model, "content": []map[string]string{{"type": "text", "text": text}}, "stop_reason": "end_turn", "usage": map[string]int{"input_tokens": 0, "output_tokens": 0}})
}
func countTokens(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]int{"input_tokens": 0})
}
func ensureServer(base string) error {
if healthy(base) {
return nil
}
if err := startBackground(); err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
for ctx.Err() == nil {
if healthy(base) {
return nil
}
time.Sleep(200 * time.Millisecond)
}
return errors.New("proxy did not start")
}
func startLaunchServer(base string) (*exec.Cmd, error) {
if healthy(base) {
return nil, nil
}
cmd, err := startServerProcess(false)
if err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
for ctx.Err() == nil {
if healthy(base) {
return cmd, nil
}
time.Sleep(200 * time.Millisecond)
}
stopManagedServer(cmd)
return nil, errors.New("proxy did not start")
}
func stopManagedServer(cmd *exec.Cmd) {
if cmd == nil || cmd.Process == nil {
return
}
_ = cmd.Process.Kill()
_, _ = cmd.Process.Wait()
_ = os.Remove(pidFile())
}
func healthy(base string) bool {
c := http.Client{Timeout: 500 * time.Millisecond}
resp, err := c.Get(base + "/health")
if err != nil {
return false
}
defer resp.Body.Close()
return resp.StatusCode == 200
}
func startBackground() error {
_, err := startServerProcess(true)
return err
}
func startServerProcess(detached bool) (*exec.Cmd, error) {
bin, err := os.Executable()
if err != nil {
return nil, err
}
if err := os.MkdirAll(configDir(), 0755); err != nil {
return nil, err
}
args := []string{"serve"}
cmd := exec.Command(bin, args...)
logf, err := os.OpenFile(filepath.Join(configDir(), "ocgo.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
return nil, err
}
cmd.Stdout, cmd.Stderr = logf, logf
cmd.Stdin = nil
if detached && runtime.GOOS != "windows" {
cmd.SysProcAttr = detachedAttrs()
}
if err := cmd.Start(); err != nil {
_ = logf.Close()
return nil, err
}
return cmd, nil
}
func configDir() string { home, _ := os.UserHomeDir(); return filepath.Join(home, ".config", "ocgo") }
func configFile() string { return filepath.Join(configDir(), "config.json") }
func pidFile() string { return filepath.Join(configDir(), "ocgo.pid") }
func saveConfig(cfg Config) error {
if err := os.MkdirAll(configDir(), 0755); err != nil {
return err
}
b, _ := json.MarshalIndent(cfg, "", " ")
if err := os.WriteFile(configFile(), append(b, '\n'), 0600); err != nil {
return err
}
fmt.Printf("Saved config to %s\n", configFile())
return nil
}
func loadConfig() (Config, error) {
cfg := Config{Host: defaultHost, Port: defaultPort, APIKey: os.Getenv("OCGO_API_KEY")}
b, err := os.ReadFile(configFile())
if err == nil {
_ = json.Unmarshal(b, &cfg)
}
if cfg.APIKey == "" {
return cfg, errors.New("missing API key; run: ocgo setup")
}
if cfg.Host == "" {
cfg.Host = defaultHost
}
if cfg.Port == 0 {
cfg.Port = defaultPort
}
return cfg, nil
}
func readPID() (int, error) {
b, err := os.ReadFile(pidFile())
if err != nil {
return 0, err
}
var pid int
_, err = fmt.Sscan(string(b), &pid)
return pid, err
}
func findListenerPID(port int) (int, error) {
if port == 0 {
return 0, errors.New("missing port")
}
out, err := exec.Command("lsof", "-nP", "-tiTCP:"+strconv.Itoa(port), "-sTCP:LISTEN").Output()
if err != nil {
return 0, err
}
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
pid, err := strconv.Atoi(line)
if err == nil && pid > 0 {
return pid, nil
}
}
return 0, errors.New("no listener found")
}

10
go.mod Normal file
View file

@ -0,0 +1,10 @@
module ocgo
go 1.22
require github.com/spf13/cobra v1.8.1
require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
)

10
go.sum Normal file
View file

@ -0,0 +1,10 @@
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

161
scripts/release.sh Executable file
View file

@ -0,0 +1,161 @@
#!/usr/bin/env bash
set -euo pipefail
APP_NAME="${APP_NAME:-ocgo}"
CMD_PATH="${CMD_PATH:-./cmd/ocgo}"
TAG="${1:-${TAG:-}}"
if [[ -z "$TAG" ]]; then
echo "Usage: $0 v0.1.0"
echo " or: TAG=v0.1.0 make release"
exit 1
fi
VERSION="${TAG#v}"
REPO="${GITHUB_REPOSITORY:-emanuelcasco/ocgo}"
if [[ -z "$REPO" ]]; then
origin_url="$(git config --get remote.origin.url || true)"
if [[ "$origin_url" =~ github.com[:/]([^/]+)/([^/.]+)(\.git)?$ ]]; then
REPO="${BASH_REMATCH[1]}/${BASH_REMATCH[2]}"
else
echo "Set GITHUB_REPOSITORY=owner/repo, or configure a GitHub origin remote."
exit 1
fi
fi
HOMEBREW_TAP_REPO="${HOMEBREW_TAP_REPO:-emanuelcasco/homebrew-tap}"
if ! command -v gh >/dev/null 2>&1; then
echo "GitHub CLI is required: brew install gh && gh auth login"
exit 1
fi
if ! gh auth status >/dev/null 2>&1; then
echo "GitHub CLI is not authenticated. Run: gh auth login"
exit 1
fi
if ! command -v go >/dev/null 2>&1; then
echo "Go is required."
exit 1
fi
if ! git diff --quiet || ! git diff --cached --quiet; then
echo "Working tree has uncommitted changes. Commit or stash them first."
exit 1
fi
# Verify the project builds/tests before tagging.
go test ./...
if ! git rev-parse "$TAG" >/dev/null 2>&1; then
git tag -a "$TAG" -m "$TAG"
fi
git push origin "$TAG"
DIST_DIR="dist"
rm -rf "$DIST_DIR"
mkdir -p "$DIST_DIR"
build_one() {
local goos="$1"
local goarch="$2"
local arch_name="$goarch"
if [[ "$goarch" == "amd64" ]]; then
arch_name="x86_64"
fi
local dir="$DIST_DIR/${APP_NAME}_${VERSION}_${goos}_${arch_name}"
mkdir -p "$dir"
local bin="$APP_NAME"
if [[ "$goos" == "windows" ]]; then
bin="$APP_NAME.exe"
fi
echo "Building $goos/$goarch..."
CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" \
go build -trimpath -ldflags "-s -w -X main.version=$VERSION" -o "$dir/$bin" "$CMD_PATH"
cp README.md "$dir/" 2>/dev/null || true
cp LICENSE "$dir/" 2>/dev/null || true
tar -C "$DIST_DIR" -czf "$dir.tar.gz" "$(basename "$dir")"
rm -rf "$dir"
}
build_one darwin amd64
build_one darwin arm64
build_one linux amd64
build_one linux arm64
(
cd "$DIST_DIR"
shasum -a 256 *.tar.gz > checksums.txt
)
if gh release view "$TAG" --repo "$REPO" >/dev/null 2>&1; then
echo "GitHub release $TAG already exists; uploading artifacts with --clobber."
gh release upload "$TAG" "$DIST_DIR"/*.tar.gz "$DIST_DIR/checksums.txt" --repo "$REPO" --clobber
else
gh release create "$TAG" "$DIST_DIR"/*.tar.gz "$DIST_DIR/checksums.txt" \
--repo "$REPO" \
--title "$TAG" \
--generate-notes
fi
# Update Homebrew tap formula to install the macOS artifacts.
TAP_TMP="$(mktemp -d)"
trap 'rm -rf "$TAP_TMP"' EXIT
gh repo clone "$HOMEBREW_TAP_REPO" "$TAP_TMP" -- --quiet
mkdir -p "$TAP_TMP/Formula"
DARWIN_ARM_SHA="$(shasum -a 256 "$DIST_DIR/${APP_NAME}_${VERSION}_darwin_arm64.tar.gz" | awk '{print $1}')"
DARWIN_AMD_SHA="$(shasum -a 256 "$DIST_DIR/${APP_NAME}_${VERSION}_darwin_x86_64.tar.gz" | awk '{print $1}')"
cat > "$TAP_TMP/Formula/${APP_NAME}.rb" <<EOF_FORMULA
class Ocgo < Formula
desc "Run Claude Code through an OpenCode Go-compatible Anthropic proxy"
homepage "https://github.com/${REPO}"
version "${VERSION}"
license "MIT"
on_macos do
if Hardware::CPU.arm?
url "https://github.com/${REPO}/releases/download/${TAG}/${APP_NAME}_${VERSION}_darwin_arm64.tar.gz"
sha256 "${DARWIN_ARM_SHA}"
else
url "https://github.com/${REPO}/releases/download/${TAG}/${APP_NAME}_${VERSION}_darwin_x86_64.tar.gz"
sha256 "${DARWIN_AMD_SHA}"
end
end
def install
bin.install "${APP_NAME}"
end
test do
system "#{bin}/${APP_NAME}", "--help"
end
end
EOF_FORMULA
(
cd "$TAP_TMP"
git add "Formula/${APP_NAME}.rb"
if git diff --cached --quiet; then
echo "Homebrew formula is already up to date."
else
git commit -m "Update ${APP_NAME} to ${TAG}"
git push
fi
)
TAP_OWNER="${HOMEBREW_TAP_REPO%%/*}"
TAP_REPO_NAME="${HOMEBREW_TAP_REPO#*/}"
TAP_NAME="${TAP_REPO_NAME#homebrew-}"
echo "Release complete: https://github.com/${REPO}/releases/tag/${TAG}"
echo "Install with: brew install ${TAP_OWNER}/${TAP_NAME}/${APP_NAME}"