diff --git a/.agents/skills/build-go-cobra-clis/SKILL.md b/.agents/skills/build-go-cobra-clis/SKILL.md index 3720b2b..df4c1ad 100644 --- a/.agents/skills/build-go-cobra-clis/SKILL.md +++ b/.agents/skills/build-go-cobra-clis/SKILL.md @@ -37,6 +37,8 @@ cmd/ agent-tools/ main.go github-issue-create/ + .env.example + README.md main.go internal/ cli/ @@ -60,6 +62,8 @@ Rules: - Let standalone binaries wrap the same command package used by the suite command. - Keep reusable non-CLI behavior outside Cobra packages so it can be tested without CLI wiring. - Use `pkg/` only when external import is intentional. +- Add `cmd//README.md` for every exported binary with usage, required inputs, output formats, build, and package examples. +- Add `cmd//.env.example` next to that README for every environment variable the binary reads. Leave secret values blank. ## Agent Contract @@ -81,7 +85,8 @@ Design every command so an AI agent can call it predictably: 4. Use `RunE`, `Args`, `cmd.Context()`, and returned errors. Reserve `os.Exit` for `main.go`. 5. Add local flags by default. Use persistent flags only for concerns inherited by every child command. 6. Inject I/O with `cmd.SetOut`, `cmd.SetErr`, and constructor options. Avoid `fmt.Println` in command logic. -7. Verify with tests, a binary build, `--help`, and at least one realistic command invocation. +7. Update `cmd//README.md` and `cmd//.env.example` for the tool contract. +8. Verify with tests, a binary build, `--help`, and at least one realistic command invocation. ## Command Pattern diff --git a/.agents/skills/release-agent-binaries/SKILL.md b/.agents/skills/release-agent-binaries/SKILL.md new file mode 100644 index 0000000..f4d7217 --- /dev/null +++ b/.agents/skills/release-agent-binaries/SKILL.md @@ -0,0 +1,135 @@ +--- +name: release-agent-binaries +description: Version, package, tag, push, and verify standalone Go CLI binaries in the agent-cli-tools repo. Use when the user asks to release, deploy, publish, version, bump, package, or install one of the binaries under cmd/*, especially with per-binary SemVer tags such as slack-post-v0.1.0 and Makefile targets. +--- + +# Release Agent Binaries + +## Overview + +Release each binary independently from this repo. Use per-binary SemVer tags, not repo-wide `v*` tags: + +```text +-v.. +slack-post-v0.1.0 +``` + +Use the Makefile as the source of truth for validation, version stamping, archive naming, tag creation, and tag push commands. + +## Workflow + +1. Identify the binary: + +```bash +make list +test -d "cmd/" +``` + +If the requested tool is not listed, stop and tell the user which binaries are available. + +2. Determine the version: + +- If the user gives an exact version, require a leading `v`, for example `v0.1.0`. +- If the user asks for `major`, `minor`, or `patch`, inspect existing tags for that binary and calculate the next SemVer. +- If there is no existing tag for that binary, default the first release to `v0.1.0` unless the user requested a different version. + +Useful commands: + +```bash +git fetch --tags +git tag -l "-v*" --sort=-version:refname +make print-tag TOOL= VERSION= +``` + +3. Confirm the release points at the intended code: + +```bash +git status --short --branch +git log --oneline -5 +``` + +Do not create a release tag on stale or unintended code. If there are uncommitted changes that should be part of the release, finish and verify them first. If unrelated local changes exist, leave them alone and explain the release boundary. + +4. Verify before tagging: + +```bash +make test +make build-tool TOOL= VERSION= +dist/ --version +make package TOOL= VERSION= GOOS=linux GOARCH=amd64 +tar -tzf dist/__linux_amd64.tar.gz +``` + +Expected archive contents: one executable named exactly ``. + +5. Create and push the per-binary tag when the user has asked to release or deploy: + +```bash +make tag TOOL= VERSION= +make push-tag TOOL= VERSION= +``` + +The pushed tag should trigger `.github/workflows/release.yml`, which builds that one binary for Linux and macOS on `amd64` and `arm64`. + +6. Verify the GitHub release: + +```bash +gh release view "-" --json tagName,name,assets,url +gh run list --workflow release.yml --limit 5 +``` + +Confirm the release contains these assets: + +```text +__linux_amd64.tar.gz +__linux_arm64.tar.gz +__darwin_amd64.tar.gz +__darwin_arm64.tar.gz +``` + +## Makefile Commands + +Use these targets instead of hand-written build/tag commands: + +```bash +make help +make list +make test +make build +make build-tool TOOL=slack-post VERSION=v0.1.0 +make package TOOL=slack-post VERSION=v0.1.0 GOOS=linux GOARCH=amd64 +make release-archives TOOL=slack-post VERSION=v0.1.0 +make print-tag TOOL=slack-post VERSION=v0.1.0 +make tag TOOL=slack-post VERSION=v0.1.0 +make push-tag TOOL=slack-post VERSION=v0.1.0 +``` + +`make package` and `make release-archives` require valid SemVer with a leading `v`. Bad examples such as `0.1.0` should fail before any archive or tag is created. + +## Docker Install Updates + +When the user asks to update a Docker install command, use this private-release URL shape: + +```dockerfile +ARG AGENT_CLI_TOOLS_VERSION=v0.1.0 +ARG TARGETOS=linux +ARG TARGETARCH=amd64 + +RUN --mount=type=secret,id=github_token \ + GITHUB_TOKEN="$(cat /run/secrets/github_token)" \ + && curl -fsSL -H "Authorization: Bearer ${GITHUB_TOKEN}" -L \ + "https://github.com/berrydev-ai/agent-cli-tools/releases/download/slack-post-${AGENT_CLI_TOOLS_VERSION}/slack-post_${AGENT_CLI_TOOLS_VERSION}_${TARGETOS}_${TARGETARCH}.tar.gz" \ + | tar -xz -C /usr/local/bin slack-post \ + && chmod +x /usr/local/bin/slack-post +``` + +Replace `slack-post` with the requested tool name. + +## Response Checklist + +When finished, report: + +- Tool and version released. +- Tag name. +- Verification commands that passed. +- Release URL or the exact blocker if GitHub release publication failed. diff --git a/.agents/skills/release-agent-binaries/agents/openai.yaml b/.agents/skills/release-agent-binaries/agents/openai.yaml new file mode 100644 index 0000000..2c72f1a --- /dev/null +++ b/.agents/skills/release-agent-binaries/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Release Agent Binaries" + short_description: "Version and release repo CLI binaries" + default_prompt: "Use $release-agent-binaries to version and release the slack-post binary." diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..919233f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,22 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + - run: go test ./... + - run: make build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..269c201 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,112 @@ +name: Release + +on: + push: + tags: + - "*-v*" + workflow_dispatch: + inputs: + tool: + description: "Binary to build from cmd/" + required: true + default: "slack-post" + version: + description: "SemVer to stamp into the binary, e.g. v0.1.0" + required: true + default: "v0.1.0" + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + - run: go test ./... + + release: + needs: test + runs-on: ubuntu-latest + permissions: + contents: read + strategy: + fail-fast: false + matrix: + goos: + - linux + - darwin + goarch: + - amd64 + - arm64 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + - name: Resolve release target + shell: bash + env: + INPUT_TOOL: ${{ github.event.inputs.tool }} + INPUT_VERSION: ${{ github.event.inputs.version }} + run: | + set -euo pipefail + if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then + tag="${GITHUB_REF_NAME}" + tool="" + version="" + for dir in cmd/*/; do + candidate="$(basename "${dir}")" + if [[ "${tag}" == "${candidate}-v"* ]]; then + tool="${candidate}" + version="${tag#${candidate}-}" + break + fi + done + if [[ -z "${tool}" || -z "${version}" ]]; then + echo "release tag must match a known cmd/ binary, e.g. slack-post-v0.1.0" >&2 + exit 1 + fi + else + tool="${INPUT_TOOL}" + version="${INPUT_VERSION}" + fi + expected="$(make print-tag TOOL="${tool}" VERSION="${version}")" + if [[ "${GITHUB_REF_TYPE}" == "tag" && "${tag}" != "${expected}" ]]; then + echo "release tag ${tag} does not match normalized tag ${expected}" >&2 + exit 1 + fi + echo "TOOL=${tool}" >> "${GITHUB_ENV}" + echo "VERSION=${version}" >> "${GITHUB_ENV}" + - name: Build binaries + run: make package TOOL="${TOOL}" VERSION="${VERSION}" GOOS="${GOOS}" GOARCH="${GOARCH}" + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + - uses: actions/upload-artifact@v4 + with: + name: binaries-${{ matrix.goos }}-${{ matrix.goarch }} + path: dist/*.tar.gz + if-no-files-found: error + + publish: + needs: release + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/') + permissions: + contents: write + steps: + - uses: actions/download-artifact@v4 + with: + path: dist + merge-multiple: true + - uses: softprops/action-gh-release@v2 + with: + files: dist/*.tar.gz diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..849ddff --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +dist/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..823183b --- /dev/null +++ b/Makefile @@ -0,0 +1,92 @@ +SHELL := /bin/bash + +BINS := $(notdir $(patsubst %/,%,$(wildcard cmd/*/))) +DIST_DIR ?= dist +TOOL ?= +VERSION ?= +BUILD_VERSION := $(if $(VERSION),$(VERSION),dev) +GOOS ?= $(shell go env GOOS) +GOARCH ?= $(shell go env GOARCH) +RELEASE_OSES ?= linux darwin +RELEASE_ARCHES ?= amd64 arm64 +TAG_NAME = $(TOOL)-$(VERSION) +ARCHIVE = $(DIST_DIR)/$(TOOL)_$(VERSION)_$(GOOS)_$(GOARCH).tar.gz +export TOOL +export VERSION + +.PHONY: help list test build build-tool package release-archives print-tag tag push-tag clean check-tool check-version check-build-version + +help: + @printf 'Targets:\n' + @printf ' make list\n' + @printf ' make test\n' + @printf ' make build\n' + @printf ' make build-tool TOOL=slack-post VERSION=v0.1.0\n' + @printf ' make package TOOL=slack-post VERSION=v0.1.0 GOOS=linux GOARCH=amd64\n' + @printf ' make release-archives TOOL=slack-post VERSION=v0.1.0\n' + @printf ' make print-tag TOOL=slack-post VERSION=v0.1.0\n' + @printf ' make tag TOOL=slack-post VERSION=v0.1.0\n' + @printf ' make push-tag TOOL=slack-post VERSION=v0.1.0\n' + +list: + @printf '%s\n' $(BINS) + +test: + go test ./... + +build: check-build-version + @mkdir -p "$(DIST_DIR)" + @set -euo pipefail; \ + for bin in $(BINS); do \ + echo "building $$bin ($(BUILD_VERSION))"; \ + go build -trimpath -ldflags "-s -w -X main.version=$(BUILD_VERSION)" -o "$(DIST_DIR)/$$bin" "./cmd/$$bin"; \ + done + +build-tool: check-tool check-build-version + @mkdir -p "$(DIST_DIR)" + go build -trimpath -ldflags "-s -w -X main.version=$(BUILD_VERSION)" -o "$(DIST_DIR)/$(TOOL)" "./cmd/$(TOOL)" + +package: check-tool check-version + @mkdir -p "$(DIST_DIR)" + @set -euo pipefail; \ + tmp_dir="$$(mktemp -d)"; \ + trap 'rm -rf "$$tmp_dir"' EXIT; \ + echo "packaging $(TOOL) $(VERSION) for $(GOOS)/$(GOARCH)"; \ + GOOS="$(GOOS)" GOARCH="$(GOARCH)" CGO_ENABLED=0 \ + go build -trimpath -ldflags "-s -w -X main.version=$(VERSION)" -o "$$tmp_dir/$(TOOL)" "./cmd/$(TOOL)"; \ + tar -C "$$tmp_dir" -czf "$(ARCHIVE)" "$(TOOL)"; \ + echo "$(ARCHIVE)" + +release-archives: check-tool check-version + @set -euo pipefail; \ + for os in $(RELEASE_OSES); do \ + for arch in $(RELEASE_ARCHES); do \ + $(MAKE) --no-print-directory package TOOL="$(TOOL)" VERSION="$(VERSION)" GOOS="$$os" GOARCH="$$arch"; \ + done; \ + done + +print-tag: check-tool check-version + @echo "$(TAG_NAME)" + +tag: check-tool check-version + @if git rev-parse -q --verify "refs/tags/$(TAG_NAME)" >/dev/null; then \ + echo "tag $(TAG_NAME) already exists" >&2; \ + exit 1; \ + fi + git tag -a "$(TAG_NAME)" -m "Release $(TOOL) $(VERSION)" + @echo "created tag $(TAG_NAME)" + +push-tag: check-tool check-version + git push origin "$(TAG_NAME)" + +clean: + rm -rf "$(DIST_DIR)" + +check-tool: + @scripts/validate-release-inputs.sh tool + +check-version: + @scripts/validate-release-inputs.sh version + +check-build-version: + @if [ -n "$${VERSION:-}" ]; then scripts/validate-release-inputs.sh version; fi diff --git a/README.md b/README.md index 769401c..0f8f16b 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,114 @@ # Agent CLI Tools -This repo contains many golang-built CLI tools that compile and can be added as a tool and/or skill for your agents. +Agent CLI Tools is a home for small Go CLIs that should be easy for AI agents to install and call as executable tools. + +## Shape + +Use one repository and one Go module, but export each tool as its own binary under `cmd/`. + +```text +cmd/ + slack-post/ + .env.example + README.md + main.go +internal/ + cli/ + slackpost/ + command.go + command_test.go +``` + +This keeps unrelated tools from sharing a command namespace while still letting the repo share test, build, release, and helper code. If a future agent runtime benefits from one suite command, add a thin `cmd/agent-cli-tools` wrapper that reuses the same internal command constructors. + +## Add a CLI + +1. Add a new `cmd//main.go`. +2. Put Cobra command behavior under `internal/cli//`. +3. Keep required inputs available as flags and environment variables. +4. Support `--format json` when output may be consumed by an agent. +5. Add command tests under the same internal package. +6. Define `var version = "dev"` in the `main` package so release builds can stamp the tag into `--version`. +7. Add `cmd//README.md` with usage, inputs, output, build, and package notes. +8. Add or update `cmd//.env.example` for every environment variable the tool reads. + +## Build + +```bash +make build +``` + +Binaries are written to `dist/`. + +## Test + +```bash +make test +``` + +## Tool Docs + +Each binary owns a README in its command path: + +- `cmd/slack-post/README.md` + +Each binary also owns its environment template next to that README: + +- `cmd/slack-post/.env.example` + +Leave secret values blank. + +## Per-Binary SemVer + +Each binary gets its own SemVer tag: + +```text +slack-post-v0.1.0 +``` + +The Makefile validates tool names and versions, stamps the version into the binary, and creates release archives with stable names. + +```bash +make print-tag TOOL=slack-post VERSION=v0.1.0 +make release-archives TOOL=slack-post VERSION=v0.1.0 +make tag TOOL=slack-post VERSION=v0.1.0 +make push-tag TOOL=slack-post VERSION=v0.1.0 +``` + +Pushing a `*-v*` tag that matches a known `cmd/` binary runs `.github/workflows/release.yml`, builds that one binary for Linux and macOS on `amd64` and `arm64`, and publishes tarballs to the GitHub release. + +For example, a tag `slack-post-v0.1.0` produces: + +```text +slack-post_v0.1.0_linux_amd64.tar.gz +slack-post_v0.1.0_linux_arm64.tar.gz +slack-post_v0.1.0_darwin_amd64.tar.gz +slack-post_v0.1.0_darwin_arm64.tar.gz +``` + +## Docker Install Example + +```dockerfile +# syntax=docker/dockerfile:1.7 + +ARG AGENT_CLI_TOOLS_VERSION=v0.1.0 +ARG TARGETOS=linux +ARG TARGETARCH=amd64 + +RUN --mount=type=secret,id=github_token \ + GITHUB_TOKEN="$(cat /run/secrets/github_token)" \ + && curl -fsSL -H "Authorization: Bearer ${GITHUB_TOKEN}" -L \ + "https://github.com/berrydev-ai/agent-cli-tools/releases/download/slack-post-${AGENT_CLI_TOOLS_VERSION}/slack-post_${AGENT_CLI_TOOLS_VERSION}_${TARGETOS}_${TARGETARCH}.tar.gz" \ + | tar -xz -C /usr/local/bin slack-post \ + && chmod +x /usr/local/bin/slack-post +``` + +Build with a token that can read this private repository: + +```bash +docker build --secret id=github_token,env=GITHUB_TOKEN . +``` + +## Tools + +- `slack-post`: see `cmd/slack-post/README.md` diff --git a/cmd/slack-post/.env.example b/cmd/slack-post/.env.example new file mode 100644 index 0000000..ba3760c --- /dev/null +++ b/cmd/slack-post/.env.example @@ -0,0 +1,16 @@ +# slack-post +# +# Copy values into the environment that runs this CLI. Do not commit real tokens. + +# Slack bot token used for chat.postMessage. +SLACK_E2E_BOT_TOKEN= + +# Slack channel ID where slack-post sends messages, for example C0123456789. +SLACK_E2E_TARGET_CHANNEL= + +# Optional default Slack member ID to mention before the message. +SLACK_E2E_TARGET_BOT_MEMBER_ID= + +# Optional per-target member IDs. Used by `slack-post --target `. +# Example for `slack-post --target claude`: +SLACK_E2E_CLAUDE_BOT_MEMBER_ID= diff --git a/cmd/slack-post/README.md b/cmd/slack-post/README.md new file mode 100644 index 0000000..1a9d463 --- /dev/null +++ b/cmd/slack-post/README.md @@ -0,0 +1,59 @@ +# slack-post + +`slack-post` posts a message to Slack using `chat.postMessage`. + +## Usage + +```bash +slack-post --prompt "run the e2e test" +slack-post --target claude --prompt "run the e2e test" +slack-post --format json --target-member-id "$SLACK_E2E_TARGET_BOT_MEMBER_ID" "run the e2e test" +``` + +## Required Inputs + +Provide these values as flags or environment variables: + +| Flag | Environment variable | Description | +| --- | --- | --- | +| `--token` | `SLACK_E2E_BOT_TOKEN` | Slack bot token. | +| `--channel` | `SLACK_E2E_TARGET_CHANNEL` | Slack channel ID. | +| `--prompt` | none | Message text. Positional text is also accepted. | + +Optional mention target: + +| Flag | Environment variable | Description | +| --- | --- | --- | +| `--target-member-id`, `--member-id` | `SLACK_E2E_TARGET_BOT_MEMBER_ID` | Slack member ID to mention. | +| `--target ` | `SLACK_E2E__BOT_MEMBER_ID` | Resolves a named target, such as `SLACK_E2E_CLAUDE_BOT_MEMBER_ID`. | + +See the local `.env.example` for the maintained environment template. + +## Output + +Default text output: + +```text +Response Status: 200 OK +Response Body: {"ok":true,...} +``` + +Agent-readable output: + +```bash +slack-post --format json --prompt "hello" +``` + +## Build + +```bash +make build-tool TOOL=slack-post VERSION=v0.1.0 +dist/slack-post --version +``` + +## Package + +```bash +make package TOOL=slack-post VERSION=v0.1.0 GOOS=linux GOARCH=amd64 +tar -tzf dist/slack-post_v0.1.0_linux_amd64.tar.gz +``` diff --git a/cmd/slack-post/main.go b/cmd/slack-post/main.go new file mode 100644 index 0000000..c6cc173 --- /dev/null +++ b/cmd/slack-post/main.go @@ -0,0 +1,18 @@ +package main + +import ( + "context" + "fmt" + "os" + + "github.com/berrydev-ai/agent-cli-tools/internal/cli/slackpost" +) + +var version = "dev" + +func main() { + if err := slackpost.Execute(context.Background(), slackpost.Options{Version: version}); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..e0eb413 --- /dev/null +++ b/go.mod @@ -0,0 +1,10 @@ +module github.com/berrydev-ai/agent-cli-tools + +go 1.25.6 + +require github.com/spf13/cobra v1.10.2 + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a6ee3e0 --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +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.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/cli/slackpost/command.go b/internal/cli/slackpost/command.go new file mode 100644 index 0000000..e672507 --- /dev/null +++ b/internal/cli/slackpost/command.go @@ -0,0 +1,239 @@ +package slackpost + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" + + "github.com/spf13/cobra" +) + +const defaultAPIURL = "https://slack.com/api/chat.postMessage" + +type Options struct { + Out io.Writer + Err io.Writer + HTTPClient *http.Client + Env func(string) string + Version string +} + +type messagePayload struct { + Channel string `json:"channel"` + Text string `json:"text"` +} + +type slackAPIResponse struct { + OK bool `json:"ok"` + Error string `json:"error,omitempty"` +} + +type postResult struct { + Status string `json:"status"` + StatusCode int `json:"status_code"` + OK bool `json:"ok"` + Response json.RawMessage `json:"response,omitempty"` +} + +func Execute(ctx context.Context, opts Options) error { + return NewCommand(opts).ExecuteContext(ctx) +} + +func NewCommand(opts Options) *cobra.Command { + if opts.Out == nil { + opts.Out = os.Stdout + } + if opts.Err == nil { + opts.Err = os.Stderr + } + if opts.HTTPClient == nil { + opts.HTTPClient = &http.Client{Timeout: 15 * time.Second} + } + if opts.Env == nil { + opts.Env = os.Getenv + } + + var ( + token string + channel string + target string + targetMemberID string + prompt string + apiURL string + format string + ) + + cmd := &cobra.Command{ + Use: "slack-post [message]", + Short: "Post a message to Slack", + Version: opts.Version, + SilenceUsage: true, + SilenceErrors: true, + Args: cobra.ArbitraryArgs, + Example: strings.TrimSpace(` +slack-post --prompt "run the e2e test" +slack-post --target claude --prompt "run the e2e test" +slack-post --format json --target-member-id "$SLACK_E2E_TARGET_BOT_MEMBER_ID" "run the e2e test"`), + RunE: func(cmd *cobra.Command, args []string) error { + token = firstNonEmpty(token, opts.Env("SLACK_E2E_BOT_TOKEN")) + channel = firstNonEmpty(channel, opts.Env("SLACK_E2E_TARGET_CHANNEL")) + + if prompt == "" && len(args) > 0 { + prompt = strings.Join(args, " ") + } + + var targetEnvName string + if targetMemberID == "" && target != "" { + targetEnvName = "SLACK_E2E_" + normalizeEnvPart(target) + "_BOT_MEMBER_ID" + targetMemberID = opts.Env(targetEnvName) + } + if targetMemberID == "" { + targetMemberID = opts.Env("SLACK_E2E_TARGET_BOT_MEMBER_ID") + } + + if format != "text" && format != "json" { + return fmt.Errorf("--format must be text or json") + } + + var missing []string + if token == "" { + missing = append(missing, "--token or SLACK_E2E_BOT_TOKEN") + } + if channel == "" { + missing = append(missing, "--channel or SLACK_E2E_TARGET_CHANNEL") + } + if prompt == "" { + missing = append(missing, "--prompt or positional message text") + } + if target != "" && targetMemberID == "" { + missing = append(missing, "--target-member-id or "+targetEnvName) + } + if len(missing) > 0 { + return fmt.Errorf("missing required value(s): %s", strings.Join(missing, ", ")) + } + + text := prompt + if targetMemberID != "" { + text = fmt.Sprintf("<@%s> %s", targetMemberID, prompt) + } + + result, err := postMessage(cmd.Context(), opts.HTTPClient, apiURL, token, channel, text) + if err != nil { + return err + } + + return writeResult(cmd.OutOrStdout(), format, result) + }, + } + + cmd.SetOut(opts.Out) + cmd.SetErr(opts.Err) + cmd.Flags().StringVar(&token, "token", "", "Slack bot token. Defaults to SLACK_E2E_BOT_TOKEN.") + cmd.Flags().StringVar(&channel, "channel", "", "Slack channel ID. Defaults to SLACK_E2E_TARGET_CHANNEL.") + cmd.Flags().StringVar(&target, "target", "", "Target name used to resolve SLACK_E2E__BOT_MEMBER_ID.") + cmd.Flags().StringVar(&targetMemberID, "target-member-id", "", "Slack member ID to mention, for example U123ABC456.") + cmd.Flags().StringVar(&targetMemberID, "member-id", "", "Alias for --target-member-id.") + cmd.Flags().StringVar(&prompt, "prompt", "", "Prompt/message text to send.") + cmd.Flags().StringVar(&apiURL, "url", defaultAPIURL, "Slack API URL.") + cmd.Flags().StringVar(&format, "format", "text", "Output format: text or json.") + + return cmd +} + +func postMessage(ctx context.Context, client *http.Client, apiURL string, token string, channel string, text string) (postResult, error) { + payload := messagePayload{ + Channel: channel, + Text: text, + } + + jsonData, err := json.Marshal(payload) + if err != nil { + return postResult{}, fmt.Errorf("encode JSON payload: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewBuffer(jsonData)) + if err != nil { + return postResult{}, fmt.Errorf("create request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json; charset=utf-8") + + resp, err := client.Do(req) + if err != nil { + return postResult{}, fmt.Errorf("post Slack message: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return postResult{}, fmt.Errorf("read response body: %w", err) + } + + result := postResult{ + Status: resp.Status, + StatusCode: resp.StatusCode, + Response: json.RawMessage(respBody), + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return result, fmt.Errorf("Slack API HTTP error: %s", resp.Status) + } + + var slackResp slackAPIResponse + if err := json.Unmarshal(respBody, &slackResp); err != nil { + return result, fmt.Errorf("decode Slack API response: %w", err) + } + result.OK = slackResp.OK + if !slackResp.OK { + if slackResp.Error != "" { + return result, fmt.Errorf("Slack API error: %s", slackResp.Error) + } + return result, fmt.Errorf("Slack API returned ok=false") + } + + return result, nil +} + +func writeResult(out io.Writer, format string, result postResult) error { + if format == "json" { + encoder := json.NewEncoder(out) + return encoder.Encode(result) + } + + _, err := fmt.Fprintf(out, "Response Status: %s\nResponse Body: %s\n", result.Status, string(result.Response)) + return err +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + value = strings.TrimSpace(value) + if value != "" { + return value + } + } + return "" +} + +func normalizeEnvPart(value string) string { + value = strings.ToUpper(strings.TrimSpace(value)) + + var b strings.Builder + for _, r := range value { + if r >= 'A' && r <= 'Z' { + b.WriteRune(r) + } else if r >= '0' && r <= '9' { + b.WriteRune(r) + } else { + b.WriteRune('_') + } + } + + return b.String() +} diff --git a/internal/cli/slackpost/command_test.go b/internal/cli/slackpost/command_test.go new file mode 100644 index 0000000..d70081f --- /dev/null +++ b/internal/cli/slackpost/command_test.go @@ -0,0 +1,109 @@ +package slackpost + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" +) + +func TestCommandPostsPositionalPromptWithTargetEnv(t *testing.T) { + var gotAuth string + var gotPayload messagePayload + var decodeErr atomic.Value + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + if err := json.NewDecoder(r.Body).Decode(&gotPayload); err != nil { + decodeErr.Store(err) + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":true,"channel":"C123","ts":"123.456"}`)) + })) + defer server.Close() + + out, _, err := execute(t, map[string]string{ + "SLACK_E2E_BOT_TOKEN": "xoxb-test", + "SLACK_E2E_TARGET_CHANNEL": "C123", + "SLACK_E2E_CLAUDE_BOT_MEMBER_ID": "UCLAUDE", + }, "--url", server.URL, "--target", "claude", "--format", "json", "run", "the", "e2e", "test") + if err != nil { + t.Fatalf("expected success, got %v", err) + } + if value := decodeErr.Load(); value != nil { + t.Fatalf("decode request body: %v", value) + } + + if gotAuth != "Bearer xoxb-test" { + t.Fatalf("authorization header = %q", gotAuth) + } + if gotPayload.Channel != "C123" { + t.Fatalf("channel = %q", gotPayload.Channel) + } + if gotPayload.Text != "<@UCLAUDE> run the e2e test" { + t.Fatalf("text = %q", gotPayload.Text) + } + + var result postResult + if err := json.Unmarshal([]byte(out), &result); err != nil { + t.Fatalf("decode command output: %v\noutput: %s", err, out) + } + if !result.OK || result.StatusCode != http.StatusOK { + t.Fatalf("unexpected result: %+v", result) + } +} + +func TestCommandRequiresNoninteractiveInputs(t *testing.T) { + _, _, err := execute(t, nil, "--prompt", "hello") + if err == nil { + t.Fatal("expected missing input error") + } + + errText := err.Error() + for _, want := range []string{"--token or SLACK_E2E_BOT_TOKEN", "--channel or SLACK_E2E_TARGET_CHANNEL"} { + if !strings.Contains(errText, want) { + t.Fatalf("error %q does not contain %q", errText, want) + } + } +} + +func TestCommandReportsSlackAPIError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":false,"error":"channel_not_found"}`)) + })) + defer server.Close() + + _, _, err := execute(t, map[string]string{ + "SLACK_E2E_BOT_TOKEN": "xoxb-test", + "SLACK_E2E_TARGET_CHANNEL": "C123", + }, "--url", server.URL, "--prompt", "hello") + if err == nil { + t.Fatal("expected Slack API error") + } + if !strings.Contains(err.Error(), "Slack API error: channel_not_found") { + t.Fatalf("unexpected error: %v", err) + } +} + +func execute(t *testing.T, env map[string]string, args ...string) (string, string, error) { + t.Helper() + + var out bytes.Buffer + var errOut bytes.Buffer + cmd := NewCommand(Options{ + Out: &out, + Err: &errOut, + Env: func(key string) string { + return env[key] + }, + }) + cmd.SetArgs(args) + err := cmd.ExecuteContext(context.Background()) + return out.String(), errOut.String(), err +} diff --git a/scripts/validate-release-inputs.sh b/scripts/validate-release-inputs.sh new file mode 100755 index 0000000..6f68a11 --- /dev/null +++ b/scripts/validate-release-inputs.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + echo "usage: $0 tool|version" >&2 + exit 2 +} + +mode="${1:-}" +case "${mode}" in + tool) + if [[ -z "${TOOL:-}" ]]; then + echo "TOOL is required, e.g. TOOL=slack-post" >&2 + exit 2 + fi + if [[ ! "${TOOL}" =~ ^[a-z0-9][a-z0-9-]*$ ]]; then + echo "TOOL must contain only lowercase letters, digits, and hyphens" >&2 + exit 2 + fi + if [[ ! -d "cmd/${TOOL}" ]]; then + echo "unknown TOOL; run 'make list' for available binaries" >&2 + exit 2 + fi + ;; + version) + if [[ -z "${VERSION:-}" ]]; then + echo "VERSION is required, e.g. VERSION=v0.1.0" >&2 + exit 2 + fi + semver='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*))*))?(\+([0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*))?$' + if [[ ! "${VERSION}" =~ ${semver} ]]; then + echo "VERSION must be valid SemVer with a leading v, e.g. v0.1.0" >&2 + exit 2 + fi + ;; + *) + usage + ;; +esac diff --git a/slack-post/slack-post b/slack-post/slack-post deleted file mode 100755 index c0c0aad..0000000 Binary files a/slack-post/slack-post and /dev/null differ diff --git a/slack-post/slack-post.go b/slack-post/slack-post.go deleted file mode 100644 index 02450a2..0000000 --- a/slack-post/slack-post.go +++ /dev/null @@ -1,195 +0,0 @@ -package main - -import ( - "bytes" - "encoding/json" - "flag" - "fmt" - "io" - "net/http" - "os" - "strings" - "time" -) - -type SlackMessagePayload struct { - Channel string `json:"channel"` - Text string `json:"text"` -} - -type SlackAPIResponse struct { - OK bool `json:"ok"` - Error string `json:"error,omitempty"` -} - -func main() { - var ( - token string - channel string - target string - targetMemberID string - prompt string - apiURL string - ) - - flag.StringVar(&token, "token", "", "Slack bot token. Defaults to SLACK_E2E_BOT_TOKEN.") - flag.StringVar(&channel, "channel", "", "Slack channel ID. Defaults to SLACK_E2E_TARGET_CHANNEL.") - flag.StringVar(&target, "target", "", "Target name used to resolve SLACK_E2E__BOT_MEMBER_ID.") - flag.StringVar(&targetMemberID, "target-member-id", "", "Slack member ID to mention, for example U123ABC456.") - flag.StringVar(&targetMemberID, "member-id", "", "Alias for --target-member-id.") - flag.StringVar(&prompt, "prompt", "", "Prompt/message text to send.") - flag.StringVar(&apiURL, "url", "https://slack.com/api/chat.postMessage", "Slack API URL.") - - flag.Usage = func() { - fmt.Fprintf(os.Stderr, `Usage: - slack-post --prompt "your message" - -Examples: - slack-post \ - --token "$SLACK_E2E_BOT_TOKEN" \ - --channel "$SLACK_E2E_TARGET_CHANNEL" \ - --target-member-id "$SLACK_E2E_TARGET_BOT_MEMBER_ID" \ - --prompt "run the e2e test" - - slack-post --target claude --prompt "run the e2e test" - -If --target claude is provided, this command will look for: - SLACK_E2E_CLAUDE_BOT_MEMBER_ID - -Flags: -`) - flag.PrintDefaults() - } - - flag.Parse() - - token = firstNonEmpty(token, os.Getenv("SLACK_E2E_BOT_TOKEN")) - channel = firstNonEmpty(channel, os.Getenv("SLACK_E2E_TARGET_CHANNEL")) - - if prompt == "" && flag.NArg() > 0 { - prompt = strings.Join(flag.Args(), " ") - } - - var targetEnvName string - if targetMemberID == "" && target != "" { - targetEnvName = "SLACK_E2E_" + normalizeEnvPart(target) + "_BOT_MEMBER_ID" - targetMemberID = os.Getenv(targetEnvName) - } - - if targetMemberID == "" { - targetMemberID = os.Getenv("SLACK_E2E_TARGET_BOT_MEMBER_ID") - } - - var missing []string - - if token == "" { - missing = append(missing, "--token or SLACK_E2E_BOT_TOKEN") - } - - if channel == "" { - missing = append(missing, "--channel or SLACK_E2E_TARGET_CHANNEL") - } - - if prompt == "" { - missing = append(missing, "--prompt or positional message text") - } - - if target != "" && targetMemberID == "" { - missing = append(missing, "--target-member-id or "+targetEnvName) - } - - if len(missing) > 0 { - fmt.Fprintf(os.Stderr, "Missing required value(s): %s\n\n", strings.Join(missing, ", ")) - flag.Usage() - os.Exit(2) - } - - text := prompt - if targetMemberID != "" { - text = fmt.Sprintf("<@%s> %s", targetMemberID, prompt) - } - - payload := SlackMessagePayload{ - Channel: channel, - Text: text, - } - - jsonData, err := json.Marshal(payload) - if err != nil { - fmt.Fprintf(os.Stderr, "Error encoding JSON payload: %s\n", err) - os.Exit(1) - } - - req, err := http.NewRequest(http.MethodPost, apiURL, bytes.NewBuffer(jsonData)) - if err != nil { - fmt.Fprintf(os.Stderr, "Error creating request: %s\n", err) - os.Exit(1) - } - - req.Header.Set("Authorization", "Bearer "+token) - req.Header.Set("Content-Type", "application/json; charset=utf-8") - - client := &http.Client{ - Timeout: 15 * time.Second, - } - - resp, err := client.Do(req) - if err != nil { - fmt.Fprintf(os.Stderr, "Error making request: %s\n", err) - os.Exit(1) - } - defer resp.Body.Close() - - respBody, err := io.ReadAll(resp.Body) - if err != nil { - fmt.Fprintf(os.Stderr, "Error reading response body: %s\n", err) - os.Exit(1) - } - - fmt.Println("Response Status:", resp.Status) - fmt.Println("Response Body:", string(respBody)) - - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - os.Exit(1) - } - - var slackResp SlackAPIResponse - if err := json.Unmarshal(respBody, &slackResp); err == nil { - if !slackResp.OK { - if slackResp.Error != "" { - fmt.Fprintf(os.Stderr, "Slack API error: %s\n", slackResp.Error) - } else { - fmt.Fprintln(os.Stderr, "Slack API returned ok=false") - } - os.Exit(1) - } - } -} - -func firstNonEmpty(values ...string) string { - for _, value := range values { - value = strings.TrimSpace(value) - if value != "" { - return value - } - } - return "" -} - -func normalizeEnvPart(value string) string { - value = strings.ToUpper(strings.TrimSpace(value)) - - var b strings.Builder - - for _, r := range value { - if r >= 'A' && r <= 'Z' { - b.WriteRune(r) - } else if r >= '0' && r <= '9' { - b.WriteRune(r) - } else { - b.WriteRune('_') - } - } - - return b.String() -} \ No newline at end of file