Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
sem-ai
*.exe
dist/
*.mcpb
.planning/
.DS_Store
.env
Expand Down
7 changes: 7 additions & 0 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ builds:
goarch: arm64
ldflags:
- -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}
hooks:
post:
- 'bash scripts/mcpb-pack.sh "{{ .Path }}" "{{ .Os }}" "{{ .Arch }}" "{{ .Version }}"'

archives:
- id: default
Expand All @@ -31,6 +34,10 @@ archives:
formats:
- zip

release:
extra_files:
- glob: ./dist/*.mcpb

checksum:
name_template: "checksums.txt"

Expand Down
2 changes: 2 additions & 0 deletions .semaphore/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ blocks:
- cache restore go-mod-$(checksum go.sum),go-mod-main
- cache restore go-build-$SEMAPHORE_GIT_BRANCH,go-build-main
- export GITHUB_TOKEN="$GITHUB_TOKEN_PAT"
- sem-version node 20
- npm install -g @anthropic-ai/mcpb
- curl -sfL https://goreleaser.com/static/run -o /tmp/goreleaser-run
- chmod +x /tmp/goreleaser-run
- /tmp/goreleaser-run release --clean --skip=before
Expand Down
5 changes: 5 additions & 0 deletions .semaphore/semaphore.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ blocks:
commands:
- make check-versions

- name: MCPB manifest
commands:
- sem-version node 20
- npx -y @anthropic-ai/mcpb validate mcpb/manifest.json

- name: Lint
commands:
- curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s -- -b "$(go env GOPATH)/bin" v2.12.2
Expand Down
10 changes: 8 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ DATE := $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
LDFLAGS := -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(DATE)
INSTALL := /usr/local/bin

.PHONY: build install uninstall clean test fmt vet release tag check-versions
.PHONY: build install uninstall clean test fmt vet mcpb release tag check-versions

build:
go build -ldflags "$(LDFLAGS)" -o $(BINARY) .
Expand All @@ -30,7 +30,13 @@ fmt:
vet:
go vet ./...

# Check plugin manifests are consistent (all three carry the same version).
# Build a local MCPB (.mcpb) bundle for the current platform into dist/.
# Requires node — uses the official `mcpb` packer (npx fetches it if absent).
# make mcpb
mcpb: build
@./scripts/mcpb-pack.sh "$(BINARY)" "$$(go env GOOS)" "$$(go env GOARCH)" dev

# Check plugin manifests are consistent (all four carry the same version).
# make check-versions # files match each other
# make check-versions TAG=0.1.8 # files also match the given tag
check-versions:
Expand Down
5 changes: 5 additions & 0 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -120,3 +120,8 @@ say " /plugin marketplace update semaphoreio # already installed — re
say " /reload-plugins # apply changes without restart"
say ""
say "Then run /sem-ai:init in a repo to set up Semaphore CI/CD for it."
say ""
say "Prefer Claude Desktop? Install the one-click MCPB extension instead of the CLI —"
say "download sem-ai_${ver}_${os}_${arch}.mcpb from the release and open it:"
say " ${DL_BASE}/${tag}/sem-ai_${ver}_${os}_${arch}.mcpb"
say "It bundles this same server; enter your org host + API token in the install dialog."
43 changes: 43 additions & 0 deletions mcpb/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# MCPB desktop extension

[MCPB](https://github.com/anthropics/mcpb) (MCP Bundle) packages the `sem-ai`
MCP server as a one-click desktop extension for Claude Desktop and other
MCPB-aware apps. The user installs a `.mcpb`, enters their organization host and
API token in the install dialog (token stored in the OS keychain), and the app
launches `sem-ai mcp` for them — no terminal, no `~/.sem.yaml` editing.

## How config flows

The bundle injects the user's input as environment variables:

| Manifest `user_config` | Env var | Read by |
| ---------------------- | --------------------- | ---------------------- |
| `host` | `SEMAPHORE_HOST` | `config.Load()` |
| `api_token` | `SEMAPHORE_API_TOKEN` | `config.Load()` |

Env wins over `~/.sem.yaml`, so the same env vars also configure the plain CLI
in CI or scripted contexts.

## Packaging

One bundle ships per OS+arch — MCPB `platform_overrides` keys on OS only, not
CPU architecture. Packing is folded into the GoReleaser run: a build post-hook
calls `scripts/mcpb-pack.sh` for each freshly built binary, which stages
`manifest.json` + `server/<binary>` and runs the official `mcpb pack`. The
resulting `dist/*.mcpb` files are attached to the GitHub release via
`release.extra_files` in `.goreleaser.yaml` — no separate step, no re-download
of published assets. The release job installs `mcpb` (`npm i -g
@anthropic-ai/mcpb`) before invoking GoReleaser.

`mcpb/manifest.json` is version-synced with the plugin manifests via
`scripts/release.sh` and guarded by `scripts/check-manifest-versions.sh`.

## Local dev

```sh
make mcpb # build + pack into dist/ (needs node)
npx @anthropic-ai/mcpb info dist/sem-ai_dev_*.mcpb
```

Open the resulting `dist/sem-ai_dev_*.mcpb` with Claude Desktop to test the
install dialog.
57 changes: 57 additions & 0 deletions mcpb/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
{
"manifest_version": "0.3",
"name": "sem-ai",
"display_name": "Semaphore CI",
"version": "0.1.24",
"description": "Agent-first Semaphore CI/CD — manage pipelines, tests, deploys, flaky detection.",
"long_description": "sem-ai exposes the full Semaphore CI/CD loop as MCP tools: inspect pipelines and workflows, read job logs, surface flaky tests and insights, manage secrets and notifications, trigger deploys, and validate YAML. Connect with your organization host and a Semaphore API token.",
"author": {
"name": "Semaphore CI",
"url": "https://semaphoreci.com"
},
"repository": {
"type": "git",
"url": "https://github.com/semaphoreio/sem-ai.git"
},
"homepage": "https://semaphoreci.com",
"documentation": "https://github.com/semaphoreio/sem-ai",
"support": "https://github.com/semaphoreio/sem-ai/issues",
"license": "Apache-2.0",
"keywords": ["semaphore", "ci", "cd", "pipelines", "devops"],
"server": {
"type": "binary",
"entry_point": "server/sem-ai",
"mcp_config": {
"command": "${__dirname}/server/sem-ai",
"args": ["mcp"],
"env": {
"SEMAPHORE_HOST": "${user_config.host}",
"SEMAPHORE_API_TOKEN": "${user_config.api_token}"
},
"platform_overrides": {
"win32": {
"command": "${__dirname}/server/sem-ai.exe"
}
}
}
},
"user_config": {
"host": {
"type": "string",
"title": "Organization Host",
"description": "Your Semaphore organization host, e.g. myorg.semaphoreci.com",
"required": true
},
"api_token": {
"type": "string",
"title": "API Token",
"description": "Semaphore API token (Account Settings → API Tokens)",
"sensitive": true,
"required": true
}
},
"tools_generated": true,
"compatibility": {
"platforms": ["darwin", "win32", "linux"]
}
}
24 changes: 20 additions & 4 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,17 @@ package config

import (
"fmt"
"os"
"sort"

"github.com/spf13/viper"
)

const (
EnvToken = "SEMAPHORE_API_TOKEN"
EnvHost = "SEMAPHORE_HOST"
)

var cfg *Config

type Context struct {
Expand All @@ -16,8 +22,8 @@ type Context struct {

type Config struct {
ActiveContext string
Token string
Host string
Token string
Host string
}

func Load() {
Expand All @@ -27,11 +33,21 @@ func Load() {
cfg.Token = viper.GetString(fmt.Sprintf("contexts.%s.auth.token", cfg.ActiveContext))
cfg.Host = viper.GetString(fmt.Sprintf("contexts.%s.host", cfg.ActiveContext))
}

if t := os.Getenv(EnvToken); t != "" {
cfg.Token = t
}
if h := os.Getenv(EnvHost); h != "" {
cfg.Host = h
if cfg.ActiveContext == "" {
cfg.ActiveContext = "env"
}
}
}

func GetActiveContext() string { return cfg.ActiveContext }
func GetToken() string { return cfg.Token }
func GetHost() string { return cfg.Host }
func GetToken() string { return cfg.Token }
func GetHost() string { return cfg.Host }

func ContextList() ([]Context, error) {
raw := viper.GetStringMap("contexts")
Expand Down
88 changes: 88 additions & 0 deletions pkg/config/config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package config

import (
"testing"

"github.com/spf13/viper"
)

func setFileContext(name, token, host string) {
viper.Set("active-context", name)
viper.Set("contexts."+name+".auth.token", token)
viper.Set("contexts."+name+".host", host)
}

func TestLoad_FileContext(t *testing.T) {
viper.Reset()
t.Setenv(EnvToken, "")
t.Setenv(EnvHost, "")
setFileContext("acme", "filetok", "acme.semaphoreci.com")

Load()

if GetToken() != "filetok" {
t.Errorf("token = %q, want %q", GetToken(), "filetok")
}
if GetHost() != "acme.semaphoreci.com" {
t.Errorf("host = %q, want %q", GetHost(), "acme.semaphoreci.com")
}
if !IsConfigured() {
t.Error("IsConfigured() = false, want true")
}
}

func TestLoad_EnvOnly(t *testing.T) {
viper.Reset()
t.Setenv(EnvToken, "envtok")
t.Setenv(EnvHost, "env.semaphoreci.com")

Load()

if GetToken() != "envtok" {
t.Errorf("token = %q, want %q", GetToken(), "envtok")
}
if GetHost() != "env.semaphoreci.com" {
t.Errorf("host = %q, want %q", GetHost(), "env.semaphoreci.com")
}
if GetActiveContext() != "env" {
t.Errorf("active context = %q, want %q", GetActiveContext(), "env")
}
if !IsConfigured() {
t.Error("IsConfigured() = false, want true")
}
}

func TestLoad_EnvOverridesFile(t *testing.T) {
viper.Reset()
setFileContext("acme", "filetok", "acme.semaphoreci.com")
t.Setenv(EnvToken, "envtok")
t.Setenv(EnvHost, "env.semaphoreci.com")

Load()

if GetToken() != "envtok" {
t.Errorf("token = %q, want env to win, got %q", GetToken(), "envtok")
}
if GetHost() != "env.semaphoreci.com" {
t.Errorf("host = %q, want env to win, got %q", GetHost(), "env.semaphoreci.com")
}
if GetActiveContext() != "acme" {
t.Errorf("active context = %q, want file context preserved", GetActiveContext())
}
}

func TestLoad_EmptyEnvFallsBackToFile(t *testing.T) {
viper.Reset()
setFileContext("acme", "filetok", "acme.semaphoreci.com")
t.Setenv(EnvToken, "")
t.Setenv(EnvHost, "")

Load()

if GetToken() != "filetok" {
t.Errorf("token = %q, want file value when env blank", GetToken())
}
if GetHost() != "acme.semaphoreci.com" {
t.Errorf("host = %q, want file value when env blank", GetHost())
}
}
8 changes: 6 additions & 2 deletions scripts/check-manifest-versions.sh
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
# .claude-plugin/marketplace.json (.plugins[0].version)
# assets/plugin/plugin.json (.version)
# assets/plugin/.codex-plugin/plugin.json (.version)
# mcpb/manifest.json (.version)
#
# Exits non-zero on any mismatch with a clear diagnostic.

Expand All @@ -18,8 +19,9 @@ set -euo pipefail
MARKETPLACE=".claude-plugin/marketplace.json"
PLUGIN="assets/plugin/plugin.json"
CODEX="assets/plugin/.codex-plugin/plugin.json"
MCPB="mcpb/manifest.json"

for f in "$MARKETPLACE" "$PLUGIN" "$CODEX"; do
for f in "$MARKETPLACE" "$PLUGIN" "$CODEX" "$MCPB"; do
if [[ ! -f "$f" ]]; then
echo "ERROR: run from repo root — missing $f" >&2
exit 2
Expand All @@ -34,12 +36,14 @@ fi
market_ver=$(yq -p json -o tsv '.plugins[0].version' "$MARKETPLACE")
plugin_ver=$(yq -p json -o tsv '.version' "$PLUGIN")
codex_ver=$(yq -p json -o tsv '.version' "$CODEX")
mcpb_ver=$(yq -p json -o tsv '.version' "$MCPB")

if [[ "$market_ver" != "$plugin_ver" || "$market_ver" != "$codex_ver" ]]; then
if [[ "$market_ver" != "$plugin_ver" || "$market_ver" != "$codex_ver" || "$market_ver" != "$mcpb_ver" ]]; then
echo "FAIL: plugin manifest versions disagree" >&2
echo " $MARKETPLACE → $market_ver" >&2
echo " $PLUGIN → $plugin_ver" >&2
echo " $CODEX → $codex_ver" >&2
echo " $MCPB → $mcpb_ver" >&2
echo " bump all with: make release VERSION=<X.Y.Z>" >&2
exit 1
fi
Expand Down
Loading