Skip to content
Draft
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
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
# doc
docs
script
.worktrees

# codegen
Expand Down
13 changes: 10 additions & 3 deletions ai/agent/info.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ type AgentInfo struct {

// Scenario is the corresponding routing rule scenario
Scenario string

// NPMPackage is the canonical npm package name used by `ci install`.
// Empty for agents that aren't distributed via npm.
NPMPackage string
}

// ListAgentInfo returns information about all supported agent types
Expand All @@ -29,7 +33,8 @@ func ListAgentInfo() []AgentInfo {
"~/.claude/settings.json",
"~/.claude.json",
},
Scenario: "claude_code",
Scenario: "claude_code",
NPMPackage: "@anthropic-ai/claude-code",
},
{
Type: AgentTypeOpenCode,
Expand All @@ -38,7 +43,8 @@ func ListAgentInfo() []AgentInfo {
ConfigFiles: []string{
"~/.config/opencode/opencode.json",
},
Scenario: "opencode",
Scenario: "opencode",
NPMPackage: "opencode-ai",
},
{
Type: AgentTypeCodex,
Expand All @@ -48,7 +54,8 @@ func ListAgentInfo() []AgentInfo {
"~/.codex/config.toml",
"~/.codex/auth.json",
},
Scenario: "codex",
Scenario: "codex",
NPMPackage: "@openai/codex",
},
}
}
Expand Down
3 changes: 3 additions & 0 deletions cli/tingly-box/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ type CLI struct {
// Agent commands
Agent command.AgentCmdKong `kong:"cmd,help='Agent configuration'"`

// Headless one-shot setup for CI / fully-managed environments
CI command.CICmdKong `kong:"cmd,name='ci',help='Headless one-shot agent setup (provider + model + agent)'"`

// OAuth
OAuth command.OAuthCmdKong `kong:"cmd,name='oauth',help='OAuth authentication'"`

Expand Down
159 changes: 159 additions & 0 deletions internal/command/ci.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
// Headless one-shot agent setup for CI / fully-managed environments.
//
// `ci apply` configures a single (provider, model, agent) triple from flags
// alone, with no stdin reads and no interactive prompts. It is idempotent:
// a provider with the same name is updated in place rather than duplicated,
// and routing rules / agent config files converge to the requested state.

package command

import (
"fmt"
"os"
"strings"

"github.com/tingly-dev/tingly-box/internal/agent"
"github.com/tingly-dev/tingly-box/internal/protocol"
)

// CICmdKong is the headless setup command. The only subcommand today is
// `apply`; it is wired as the default so plain `tingly-box ci ...` keeps
// working even before any other subcommand is added.
type CICmdKong struct {
Apply CIApplyCmdKong `kong:"cmd,name='apply',default='1',help='Apply a (provider, model, agent) configuration in one shot'"`
Install CIInstallCmdKong `kong:"cmd,name='install',help='Install an agent CLI via npm'"`
}

// CIApplyCmdKong carries all flags accepted by `ci apply`. Every required
// flag is enforced inside Run so that we can emit a single combined error
// message (Kong's per-flag required errors are noisy and exit before we can
// suggest related missing flags).
type CIApplyCmdKong struct {
Agent string `kong:"flag,name='agent',help='Agent type: cc | oc | cx (aliases of claude-code / opencode / codex)'"`
ProviderURL string `kong:"flag,name='provider-url',help='Provider API base URL (used as upsert key)'"`
ProviderToken string `kong:"flag,name='provider-token',help='Provider API token'"`
ProviderStyle string `kong:"flag,name='provider-style',help='Provider API style: openai | anthropic'"`
Model string `kong:"flag,name='model',help='Model name'"`
Unified bool `kong:"flag,name='unified',default='true',negatable,help='Unified mode (claude-code only)'"`
StatusLine bool `kong:"flag,name='status-line',help='Install status line script (claude-code only)'"`
DryRun bool `kong:"flag,name='dry-run',help='Print the plan without applying changes'"`
}

// Run validates flags, parses the agent type, normalises the API style, then
// delegates the actual work to applyCISpec. Errors here exit with status 2
// (configuration error) via os.Exit so CI runners can distinguish bad input
// from runtime failures (which return through Kong as status 1).
func (c *CIApplyCmdKong) Run(appManager *AppManager) error {
spec, err := c.toSpec()
if err != nil {
fmt.Fprintf(os.Stderr, "ci: %v\n", err)
os.Exit(2)
}

if c.DryRun {
printCIPlan(spec)
return nil
}

return applyCISpec(appManager, spec)
}

// ciSpec is the validated, internal representation of a `ci apply` request.
// It is intentionally separate from CIApplyCmdKong so that tests (and any
// future YAML loader) can build one without going through Kong.
type ciSpec struct {
AgentType agent.AgentType
ProviderURL string
ProviderToken string
ProviderStyle protocol.APIStyle
Model string
Unified bool
StatusLine bool
}

// toSpec converts CLI flags into a validated ciSpec, collecting every
// missing-flag complaint into a single error so the user sees them all at
// once rather than having to fix and re-run repeatedly.
func (c *CIApplyCmdKong) toSpec() (*ciSpec, error) {
var missing []string
if strings.TrimSpace(c.Agent) == "" {
missing = append(missing, "--agent")
}
if strings.TrimSpace(c.ProviderURL) == "" {
missing = append(missing, "--provider-url")
}
if strings.TrimSpace(c.ProviderToken) == "" {
missing = append(missing, "--provider-token")
}
if strings.TrimSpace(c.ProviderStyle) == "" {
missing = append(missing, "--provider-style")
}
if strings.TrimSpace(c.Model) == "" {
missing = append(missing, "--model")
}
if len(missing) > 0 {
return nil, fmt.Errorf("missing required flag(s): %s", strings.Join(missing, ", "))
}

agentType, err := agent.ParseAgentType(c.Agent)
if err != nil {
return nil, fmt.Errorf("invalid --agent %q (accepted: cc, oc, cx)", c.Agent)
}

style, err := parseAPIStyle(c.ProviderStyle)
if err != nil {
return nil, err
}

// status-line / unified only make sense for claude-code. We don't reject
// them outright for non-cc agents (the user may be scripting a matrix and
// passing the same flags everywhere) — silently ignored below via the
// agent-specific branch in ApplyAgent.
return &ciSpec{
AgentType: agentType,
ProviderURL: c.ProviderURL,
ProviderToken: c.ProviderToken,
ProviderStyle: style,
Model: c.Model,
Unified: c.Unified,
StatusLine: c.StatusLine,
}, nil
}

// parseAPIStyle accepts "openai" / "anthropic" (case-insensitive). OAuth
// providers are intentionally rejected — they need a browser flow that has
// no place in a CI invocation.
func parseAPIStyle(s string) (protocol.APIStyle, error) {
switch strings.ToLower(strings.TrimSpace(s)) {
case "openai":
return protocol.APIStyleOpenAI, nil
case "anthropic":
return protocol.APIStyleAnthropic, nil
default:
return "", fmt.Errorf("invalid --provider-style %q (accepted: openai, anthropic)", s)
}
}

// printCIPlan emits the intended actions without touching disk. Token is
// redacted so the output is safe to capture in CI logs.
func printCIPlan(s *ciSpec) {
fmt.Println("ci apply (dry-run):")
fmt.Printf(" agent: %s\n", s.AgentType)
fmt.Printf(" provider: %s\n", s.ProviderURL)
fmt.Printf(" token: %s\n", redactToken(s.ProviderToken))
fmt.Printf(" style: %s\n", s.ProviderStyle)
fmt.Printf(" model: %s\n", s.Model)
if s.AgentType == agent.AgentTypeClaudeCode {
fmt.Printf(" unified: %v\n", s.Unified)
fmt.Printf(" status_line: %v\n", s.StatusLine)
}
}

// redactToken keeps the first/last 4 chars so an operator can sanity-check
// they passed the right secret without leaking the whole value to logs.
func redactToken(t string) string {
if len(t) <= 8 {
return "****"
}
return t[:4] + "…" + t[len(t)-4:]
}
84 changes: 84 additions & 0 deletions internal/command/ci_install.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package command

import (
"fmt"
"os"
"os/exec"
"strings"

"github.com/tingly-dev/tingly-box/internal/agent"
)

// CIInstallCmdKong installs an agent's CLI via npm. It is intentionally
// minimal: one agent per invocation, global install, sudo is NOT handled
// (npm's own EACCES message is more informative than anything we'd wrap).
type CIInstallCmdKong struct {
Agent string `kong:"flag,name='agent',help='Agent type: cc | oc | cx'"`
Version string `kong:"flag,name='version',help='Pin a specific version (default: latest)'"`
Package string `kong:"flag,name='package',help='Override the npm package name'"`
DryRun bool `kong:"flag,name='dry-run',help='Print the npm command without executing it'"`
}

// Run validates flags and shells out to npm. Validation errors exit with 2
// (configuration error); npm's exit code is propagated otherwise.
func (c *CIInstallCmdKong) Run(_ *AppManager) error {
pkgSpec, err := resolveInstallPackage(c.Agent, c.Package, c.Version)
if err != nil {
fmt.Fprintf(os.Stderr, "ci install: %v\n", err)
os.Exit(2)
}

if c.DryRun {
fmt.Printf("npm install -g %s\n", pkgSpec)
return nil
}

if _, err := exec.LookPath("npm"); err != nil {
fmt.Fprintln(os.Stderr, "ci install: npm not found in PATH; install Node.js / npm first")
os.Exit(2)
}

fmt.Printf("$ npm install -g %s\n", pkgSpec)
cmd := exec.Command("npm", "install", "-g", pkgSpec)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
if err := cmd.Run(); err != nil {
// npm already printed a meaningful error to stderr; surface its exit
// code to the caller so CI gates behave normally.
if exitErr, ok := err.(*exec.ExitError); ok {
os.Exit(exitErr.ExitCode())
}
return fmt.Errorf("failed to invoke npm: %w", err)
}
return nil
}

// resolveInstallPackage produces the "<pkg>[@version]" argument for
// `npm install -g`. It validates the agent, applies a --package override,
// and tacks on a --version pin if given. Pure (no I/O, no os.Exit) so tests
// can drive every branch.
func resolveInstallPackage(agentFlag, override, version string) (string, error) {
if strings.TrimSpace(agentFlag) == "" {
return "", fmt.Errorf("missing required flag --agent")
}

agentType, err := agent.ParseAgentType(agentFlag)
if err != nil {
return "", fmt.Errorf("invalid --agent %q (accepted: cc, oc, cx)", agentFlag)
}

pkg := strings.TrimSpace(override)
if pkg == "" {
info, ok := agent.GetAgentInfo(agentType)
if !ok || info.NPMPackage == "" {
return "", fmt.Errorf("no npm package registered for agent %s; pass --package to override", agentType)
}
pkg = info.NPMPackage
}

if v := strings.TrimSpace(version); v != "" {
pkg = pkg + "@" + v
}
return pkg, nil
}
81 changes: 81 additions & 0 deletions internal/command/ci_install_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package command

import (
"strings"
"testing"
)

func TestResolveInstallPackage_DefaultsByAgent(t *testing.T) {
cases := []struct{ agent, want string }{
{"cc", "@anthropic-ai/claude-code"},
{"claude-code", "@anthropic-ai/claude-code"},
{"oc", "opencode-ai"},
{"opencode", "opencode-ai"},
{"cx", "@openai/codex"},
{"codex", "@openai/codex"},
}
for _, c := range cases {
got, err := resolveInstallPackage(c.agent, "", "")
if err != nil {
t.Errorf("resolveInstallPackage(%q): %v", c.agent, err)
continue
}
if got != c.want {
t.Errorf("resolveInstallPackage(%q) = %q, want %q", c.agent, got, c.want)
}
}
}

func TestResolveInstallPackage_VersionAppended(t *testing.T) {
got, err := resolveInstallPackage("cc", "", "1.2.3")
if err != nil {
t.Fatal(err)
}
if got != "@anthropic-ai/claude-code@1.2.3" {
t.Errorf("got %q", got)
}
}

func TestResolveInstallPackage_OverrideTakesPrecedence(t *testing.T) {
got, err := resolveInstallPackage("cc", "my-fork-of-cc", "")
if err != nil {
t.Fatal(err)
}
if got != "my-fork-of-cc" {
t.Errorf("got %q, want my-fork-of-cc", got)
}

// Override + version combine.
got, err = resolveInstallPackage("cc", "my-fork-of-cc", "0.1.0")
if err != nil {
t.Fatal(err)
}
if got != "my-fork-of-cc@0.1.0" {
t.Errorf("got %q", got)
}
}

func TestResolveInstallPackage_MissingAgent(t *testing.T) {
_, err := resolveInstallPackage("", "", "")
if err == nil || !strings.Contains(err.Error(), "--agent") {
t.Errorf("expected --agent error, got %v", err)
}
}

func TestResolveInstallPackage_InvalidAgent(t *testing.T) {
_, err := resolveInstallPackage("nope", "", "")
if err == nil {
t.Fatal("expected error for invalid agent")
}
}

func TestResolveInstallPackage_TrimsWhitespace(t *testing.T) {
got, err := resolveInstallPackage("cc", " ", " ")
if err != nil {
t.Fatal(err)
}
// Whitespace-only override / version are equivalent to absent.
if got != "@anthropic-ai/claude-code" {
t.Errorf("got %q", got)
}
}
Loading