From bfb0d252ff53846963937d101a862151bebd9f08 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 13 May 2026 05:24:42 +0000 Subject: [PATCH 1/5] Add headless `ci apply` command for one-shot agent setup CI / fully-managed environments need to configure (provider, model, agent) without any interactive prompts. Existing `provider add` + `agent apply` both fall through to stdin reads, which hang in non-TTY contexts. This adds a flat command that takes everything via flags, upserts the provider by name (idempotent re-runs), and delegates to ApplyAgent with Force=true. --- cli/tingly-box/main.go | 3 + internal/command/ci.go | 165 ++++++++++++++++++++++++++++++++++ internal/command/ci_runner.go | 76 ++++++++++++++++ internal/command/ci_test.go | 159 ++++++++++++++++++++++++++++++++ 4 files changed, 403 insertions(+) create mode 100644 internal/command/ci.go create mode 100644 internal/command/ci_runner.go create mode 100644 internal/command/ci_test.go diff --git a/cli/tingly-box/main.go b/cli/tingly-box/main.go index f7580e9c3..28a36570f 100644 --- a/cli/tingly-box/main.go +++ b/cli/tingly-box/main.go @@ -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'"` diff --git a/internal/command/ci.go b/internal/command/ci.go new file mode 100644 index 000000000..9a1706471 --- /dev/null +++ b/internal/command/ci.go @@ -0,0 +1,165 @@ +// 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'"` +} + +// 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)'"` + ProviderName string `kong:"flag,name='provider-name',help='Provider name (used as upsert key)'"` + ProviderURL string `kong:"flag,name='provider-url',help='Provider API base URL'"` + 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 + ProviderName string + 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.ProviderName) == "" { + missing = append(missing, "--provider-name") + } + 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, + ProviderName: c.ProviderName, + 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.ProviderName) + fmt.Printf(" url: %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:] +} diff --git a/internal/command/ci_runner.go b/internal/command/ci_runner.go new file mode 100644 index 000000000..a28abaefd --- /dev/null +++ b/internal/command/ci_runner.go @@ -0,0 +1,76 @@ +package command + +import ( + "fmt" + + "github.com/tingly-dev/tingly-box/internal/agent" +) + +// applyCISpec is the runner for `ci apply`. It performs two steps: +// +// 1. upsert the provider by name (so re-running CI doesn't keep adding +// duplicate provider rows — names are not unique at the storage layer, +// but for headless setup we treat them as the upsert key). +// 2. delegate to agent.ApplyAgent with Force=true so it never blocks on a +// confirmation prompt. ApplyAgent already handles routing-rule creation +// and config-file writes idempotently when given a concrete provider +// UUID and model. +func applyCISpec(am *AppManager, s *ciSpec) error { + providerUUID, action, err := upsertProviderByName(am, s) + if err != nil { + return fmt.Errorf("provider upsert failed: %w", err) + } + fmt.Printf("provider %s: %s (%s)\n", action, s.ProviderName, providerUUID) + + req := &agent.ApplyAgentRequest{ + AgentType: s.AgentType, + Provider: providerUUID, + Model: s.Model, + Unified: s.Unified, + InstallStatusLine: s.StatusLine, + Force: true, + } + + globalConfig := am.GetGlobalConfig() + apply := agent.NewAgentApply(globalConfig, "127.0.0.1") + result, err := apply.ApplyAgent(req) + if err != nil { + return fmt.Errorf("agent apply failed: %w", err) + } + if !result.Success { + return fmt.Errorf("agent apply did not succeed: %s", result.Message) + } + + fmt.Print("\n" + result.Message) + return nil +} + +// upsertProviderByName looks up an existing provider by name and updates its +// URL/token/style if found, otherwise creates a new one. The returned action +// string ("created" or "updated") is used purely for the user-facing log line. +// +// Note: the storage layer does not enforce uniqueness on provider name, so +// in theory two providers could share a name. For CI we ignore that edge +// case — if it ever matters, the operator should give their CI provider a +// distinctive name like "ci-openrouter". +func upsertProviderByName(am *AppManager, s *ciSpec) (string, string, error) { + existing, _ := am.GetProviderByName(s.ProviderName) + if existing != nil { + // Update in place. We deliberately overwrite every field the user + // specified — that is the contract of a declarative CI flow. + existing.APIBase = s.ProviderURL + existing.Token = s.ProviderToken + existing.APIStyle = s.ProviderStyle + existing.Enabled = true + if err := am.UpdateProviderByUUID(existing.UUID, existing); err != nil { + return "", "", err + } + return existing.UUID, "updated", nil + } + + uuid, err := am.AddProvider(s.ProviderName, s.ProviderURL, s.ProviderToken, s.ProviderStyle) + if err != nil { + return "", "", err + } + return uuid, "created", nil +} diff --git a/internal/command/ci_test.go b/internal/command/ci_test.go new file mode 100644 index 000000000..1b648b5c3 --- /dev/null +++ b/internal/command/ci_test.go @@ -0,0 +1,159 @@ +package command + +import ( + "strings" + "testing" + + "github.com/tingly-dev/tingly-box/internal/agent" + "github.com/tingly-dev/tingly-box/internal/protocol" +) + +// helper: minimal valid flag set, mutated by individual tests. +func validApplyFlags() *CIApplyCmdKong { + return &CIApplyCmdKong{ + Agent: "cc", + ProviderName: "ci-openrouter", + ProviderURL: "https://openrouter.ai/api/v1", + ProviderToken: "sk-test-token-1234567890", + ProviderStyle: "openai", + Model: "anthropic/claude-sonnet-4", + Unified: true, + } +} + +func TestCIApply_toSpec_OK(t *testing.T) { + c := validApplyFlags() + spec, err := c.toSpec() + if err != nil { + t.Fatalf("toSpec: %v", err) + } + if spec.AgentType != agent.AgentTypeClaudeCode { + t.Errorf("agent type: got %v want %v", spec.AgentType, agent.AgentTypeClaudeCode) + } + if spec.ProviderStyle != protocol.APIStyleOpenAI { + t.Errorf("provider style: got %v want %v", spec.ProviderStyle, protocol.APIStyleOpenAI) + } +} + +func TestCIApply_toSpec_MissingFlagsCollected(t *testing.T) { + c := &CIApplyCmdKong{} // everything empty + _, err := c.toSpec() + if err == nil { + t.Fatal("expected error when all required flags missing") + } + msg := err.Error() + for _, want := range []string{ + "--agent", "--provider-name", "--provider-url", + "--provider-token", "--provider-style", "--model", + } { + if !strings.Contains(msg, want) { + t.Errorf("error %q missing %q", msg, want) + } + } +} + +func TestCIApply_toSpec_InvalidAgent(t *testing.T) { + c := validApplyFlags() + c.Agent = "nope" + if _, err := c.toSpec(); err == nil { + t.Fatal("expected error for invalid agent") + } +} + +func TestCIApply_toSpec_RejectsOAuthStyle(t *testing.T) { + // OAuth-style providers (e.g. "anthropic-oauth", "google") are intentionally + // out of scope for `ci apply` — they require a browser flow. + for _, bad := range []string{"google", "anthropic-oauth", "oauth", ""} { + c := validApplyFlags() + c.ProviderStyle = bad + _, err := c.toSpec() + if err == nil { + t.Errorf("provider-style %q should be rejected", bad) + } + } +} + +func TestCIApply_toSpec_StyleIsCaseInsensitive(t *testing.T) { + c := validApplyFlags() + c.ProviderStyle = "OpenAI" + spec, err := c.toSpec() + if err != nil { + t.Fatalf("toSpec: %v", err) + } + if spec.ProviderStyle != protocol.APIStyleOpenAI { + t.Errorf("expected APIStyleOpenAI, got %v", spec.ProviderStyle) + } +} + +// TestCI_UpsertProvider_CreateThenUpdate exercises the idempotency contract: +// the second call with the same provider name must update in place rather +// than create a duplicate row. +func TestCI_UpsertProvider_CreateThenUpdate(t *testing.T) { + am := newTestAppManager(t) + + first := &ciSpec{ + ProviderName: "ci-openrouter", + ProviderURL: "https://example.com/v1", + ProviderToken: "token-A", + ProviderStyle: protocol.APIStyleOpenAI, + } + uuid1, action, err := upsertProviderByName(am, first) + if err != nil { + t.Fatalf("first upsert: %v", err) + } + if action != "created" { + t.Errorf("first action: got %q want %q", action, "created") + } + + // Second call: same name, different token+url. Must update, not create. + second := &ciSpec{ + ProviderName: "ci-openrouter", + ProviderURL: "https://example.com/v2", + ProviderToken: "token-B", + ProviderStyle: protocol.APIStyleAnthropic, + } + uuid2, action, err := upsertProviderByName(am, second) + if err != nil { + t.Fatalf("second upsert: %v", err) + } + if action != "updated" { + t.Errorf("second action: got %q want %q", action, "updated") + } + if uuid1 != uuid2 { + t.Errorf("upsert created a duplicate: uuid1=%s uuid2=%s", uuid1, uuid2) + } + + // Confirm the in-place update actually took. + got, err := am.GetProviderByName("ci-openrouter") + if err != nil || got == nil { + t.Fatalf("provider lookup after upsert failed: %v", err) + } + if got.APIBase != "https://example.com/v2" { + t.Errorf("APIBase not updated: got %q", got.APIBase) + } + if got.Token != "token-B" { + t.Errorf("Token not updated: got %q", got.Token) + } + if got.APIStyle != protocol.APIStyleAnthropic { + t.Errorf("APIStyle not updated: got %q", got.APIStyle) + } + + // And there is still exactly one provider. + if got := len(am.ListProviders()); got != 1 { + t.Errorf("expected 1 provider after upsert, got %d", got) + } +} + +func TestRedactToken(t *testing.T) { + cases := []struct{ in, want string }{ + {"", "****"}, + {"short", "****"}, + {"12345678", "****"}, + {"sk-abcdefgh1234", "sk-a…1234"}, + } + for _, c := range cases { + if got := redactToken(c.in); got != c.want { + t.Errorf("redactToken(%q) = %q, want %q", c.in, got, c.want) + } + } +} From 04901184ac88d3166014921db5f7f0770d363e8e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 13 May 2026 05:31:17 +0000 Subject: [PATCH 2/5] ci: use provider URL as upsert key, drop --provider-name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Provider name added no value — URL already uniquely identifies an endpoint. Upsert now iterates ListProviders() matching APIBase; new providers use the URL as their display name too. Remove --provider-name flag and ciSpec field. --- internal/command/ci.go | 25 ++++++--------- internal/command/ci_runner.go | 50 +++++++++++++----------------- internal/command/ci_test.go | 57 ++++++++++++++++++++++++----------- 3 files changed, 69 insertions(+), 63 deletions(-) diff --git a/internal/command/ci.go b/internal/command/ci.go index 9a1706471..1fd18bf3b 100644 --- a/internal/command/ci.go +++ b/internal/command/ci.go @@ -28,15 +28,14 @@ type CICmdKong struct { // 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)'"` - ProviderName string `kong:"flag,name='provider-name',help='Provider name (used as upsert key)'"` - ProviderURL string `kong:"flag,name='provider-url',help='Provider API base URL'"` - 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'"` + 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 @@ -63,7 +62,6 @@ func (c *CIApplyCmdKong) Run(appManager *AppManager) error { // future YAML loader) can build one without going through Kong. type ciSpec struct { AgentType agent.AgentType - ProviderName string ProviderURL string ProviderToken string ProviderStyle protocol.APIStyle @@ -80,9 +78,6 @@ func (c *CIApplyCmdKong) toSpec() (*ciSpec, error) { if strings.TrimSpace(c.Agent) == "" { missing = append(missing, "--agent") } - if strings.TrimSpace(c.ProviderName) == "" { - missing = append(missing, "--provider-name") - } if strings.TrimSpace(c.ProviderURL) == "" { missing = append(missing, "--provider-url") } @@ -115,7 +110,6 @@ func (c *CIApplyCmdKong) toSpec() (*ciSpec, error) { // agent-specific branch in ApplyAgent. return &ciSpec{ AgentType: agentType, - ProviderName: c.ProviderName, ProviderURL: c.ProviderURL, ProviderToken: c.ProviderToken, ProviderStyle: style, @@ -144,8 +138,7 @@ func parseAPIStyle(s string) (protocol.APIStyle, error) { func printCIPlan(s *ciSpec) { fmt.Println("ci apply (dry-run):") fmt.Printf(" agent: %s\n", s.AgentType) - fmt.Printf(" provider: %s\n", s.ProviderName) - fmt.Printf(" url: %s\n", s.ProviderURL) + 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) diff --git a/internal/command/ci_runner.go b/internal/command/ci_runner.go index a28abaefd..4b13d4a6c 100644 --- a/internal/command/ci_runner.go +++ b/internal/command/ci_runner.go @@ -8,19 +8,17 @@ import ( // applyCISpec is the runner for `ci apply`. It performs two steps: // -// 1. upsert the provider by name (so re-running CI doesn't keep adding -// duplicate provider rows — names are not unique at the storage layer, -// but for headless setup we treat them as the upsert key). +// 1. upsert the provider by URL (idempotent: re-running CI with the same URL +// updates the existing row rather than duplicating it). // 2. delegate to agent.ApplyAgent with Force=true so it never blocks on a -// confirmation prompt. ApplyAgent already handles routing-rule creation -// and config-file writes idempotently when given a concrete provider -// UUID and model. +// confirmation prompt. ApplyAgent handles routing-rule creation and +// config-file writes idempotently when given a concrete provider UUID and model. func applyCISpec(am *AppManager, s *ciSpec) error { - providerUUID, action, err := upsertProviderByName(am, s) + providerUUID, action, err := upsertProviderByURL(am, s) if err != nil { return fmt.Errorf("provider upsert failed: %w", err) } - fmt.Printf("provider %s: %s (%s)\n", action, s.ProviderName, providerUUID) + fmt.Printf("provider %s: %s (%s)\n", action, s.ProviderURL, providerUUID) req := &agent.ApplyAgentRequest{ AgentType: s.AgentType, @@ -45,30 +43,24 @@ func applyCISpec(am *AppManager, s *ciSpec) error { return nil } -// upsertProviderByName looks up an existing provider by name and updates its -// URL/token/style if found, otherwise creates a new one. The returned action -// string ("created" or "updated") is used purely for the user-facing log line. -// -// Note: the storage layer does not enforce uniqueness on provider name, so -// in theory two providers could share a name. For CI we ignore that edge -// case — if it ever matters, the operator should give their CI provider a -// distinctive name like "ci-openrouter". -func upsertProviderByName(am *AppManager, s *ciSpec) (string, string, error) { - existing, _ := am.GetProviderByName(s.ProviderName) - if existing != nil { - // Update in place. We deliberately overwrite every field the user - // specified — that is the contract of a declarative CI flow. - existing.APIBase = s.ProviderURL - existing.Token = s.ProviderToken - existing.APIStyle = s.ProviderStyle - existing.Enabled = true - if err := am.UpdateProviderByUUID(existing.UUID, existing); err != nil { - return "", "", err +// upsertProviderByURL looks for an existing provider whose APIBase matches +// s.ProviderURL and updates it in place; otherwise creates a new one with the +// URL as its name. The returned action string ("created" or "updated") is for +// the user-facing log line only. +func upsertProviderByURL(am *AppManager, s *ciSpec) (string, string, error) { + for _, p := range am.ListProviders() { + if p.APIBase == s.ProviderURL { + p.Token = s.ProviderToken + p.APIStyle = s.ProviderStyle + p.Enabled = true + if err := am.UpdateProviderByUUID(p.UUID, p); err != nil { + return "", "", err + } + return p.UUID, "updated", nil } - return existing.UUID, "updated", nil } - uuid, err := am.AddProvider(s.ProviderName, s.ProviderURL, s.ProviderToken, s.ProviderStyle) + uuid, err := am.AddProvider(s.ProviderURL, s.ProviderURL, s.ProviderToken, s.ProviderStyle) if err != nil { return "", "", err } diff --git a/internal/command/ci_test.go b/internal/command/ci_test.go index 1b648b5c3..e3efdb6e5 100644 --- a/internal/command/ci_test.go +++ b/internal/command/ci_test.go @@ -12,7 +12,6 @@ import ( func validApplyFlags() *CIApplyCmdKong { return &CIApplyCmdKong{ Agent: "cc", - ProviderName: "ci-openrouter", ProviderURL: "https://openrouter.ai/api/v1", ProviderToken: "sk-test-token-1234567890", ProviderStyle: "openai", @@ -33,6 +32,9 @@ func TestCIApply_toSpec_OK(t *testing.T) { if spec.ProviderStyle != protocol.APIStyleOpenAI { t.Errorf("provider style: got %v want %v", spec.ProviderStyle, protocol.APIStyleOpenAI) } + if spec.ProviderURL != "https://openrouter.ai/api/v1" { + t.Errorf("provider URL: got %v", spec.ProviderURL) + } } func TestCIApply_toSpec_MissingFlagsCollected(t *testing.T) { @@ -43,13 +45,16 @@ func TestCIApply_toSpec_MissingFlagsCollected(t *testing.T) { } msg := err.Error() for _, want := range []string{ - "--agent", "--provider-name", "--provider-url", - "--provider-token", "--provider-style", "--model", + "--agent", "--provider-url", "--provider-token", "--provider-style", "--model", } { if !strings.Contains(msg, want) { t.Errorf("error %q missing %q", msg, want) } } + // --provider-name must not appear — it no longer exists + if strings.Contains(msg, "--provider-name") { + t.Errorf("error should not mention --provider-name (removed flag)") + } } func TestCIApply_toSpec_InvalidAgent(t *testing.T) { @@ -61,8 +66,6 @@ func TestCIApply_toSpec_InvalidAgent(t *testing.T) { } func TestCIApply_toSpec_RejectsOAuthStyle(t *testing.T) { - // OAuth-style providers (e.g. "anthropic-oauth", "google") are intentionally - // out of scope for `ci apply` — they require a browser flow. for _, bad := range []string{"google", "anthropic-oauth", "oauth", ""} { c := validApplyFlags() c.ProviderStyle = bad @@ -86,18 +89,17 @@ func TestCIApply_toSpec_StyleIsCaseInsensitive(t *testing.T) { } // TestCI_UpsertProvider_CreateThenUpdate exercises the idempotency contract: -// the second call with the same provider name must update in place rather -// than create a duplicate row. +// the second call with the same URL must update in place rather than create +// a duplicate provider row. func TestCI_UpsertProvider_CreateThenUpdate(t *testing.T) { am := newTestAppManager(t) first := &ciSpec{ - ProviderName: "ci-openrouter", ProviderURL: "https://example.com/v1", ProviderToken: "token-A", ProviderStyle: protocol.APIStyleOpenAI, } - uuid1, action, err := upsertProviderByName(am, first) + uuid1, action, err := upsertProviderByURL(am, first) if err != nil { t.Fatalf("first upsert: %v", err) } @@ -105,14 +107,13 @@ func TestCI_UpsertProvider_CreateThenUpdate(t *testing.T) { t.Errorf("first action: got %q want %q", action, "created") } - // Second call: same name, different token+url. Must update, not create. + // Second call: same URL, different token+style. Must update, not create. second := &ciSpec{ - ProviderName: "ci-openrouter", - ProviderURL: "https://example.com/v2", + ProviderURL: "https://example.com/v1", ProviderToken: "token-B", ProviderStyle: protocol.APIStyleAnthropic, } - uuid2, action, err := upsertProviderByName(am, second) + uuid2, action, err := upsertProviderByURL(am, second) if err != nil { t.Fatalf("second upsert: %v", err) } @@ -124,13 +125,10 @@ func TestCI_UpsertProvider_CreateThenUpdate(t *testing.T) { } // Confirm the in-place update actually took. - got, err := am.GetProviderByName("ci-openrouter") + got, err := am.GetProvider(uuid1) if err != nil || got == nil { t.Fatalf("provider lookup after upsert failed: %v", err) } - if got.APIBase != "https://example.com/v2" { - t.Errorf("APIBase not updated: got %q", got.APIBase) - } if got.Token != "token-B" { t.Errorf("Token not updated: got %q", got.Token) } @@ -138,12 +136,35 @@ func TestCI_UpsertProvider_CreateThenUpdate(t *testing.T) { t.Errorf("APIStyle not updated: got %q", got.APIStyle) } - // And there is still exactly one provider. + // Still exactly one provider. if got := len(am.ListProviders()); got != 1 { t.Errorf("expected 1 provider after upsert, got %d", got) } } +// TestCI_UpsertProvider_DifferentURLsCreateSeparate ensures two different +// URLs produce two distinct provider rows (no cross-contamination). +func TestCI_UpsertProvider_DifferentURLsCreateSeparate(t *testing.T) { + am := newTestAppManager(t) + + for _, url := range []string{"https://a.example.com/v1", "https://b.example.com/v1"} { + _, action, err := upsertProviderByURL(am, &ciSpec{ + ProviderURL: url, + ProviderToken: "tok", + ProviderStyle: protocol.APIStyleOpenAI, + }) + if err != nil { + t.Fatalf("upsert %s: %v", url, err) + } + if action != "created" { + t.Errorf("upsert %s: got action %q want created", url, action) + } + } + if got := len(am.ListProviders()); got != 2 { + t.Errorf("expected 2 providers, got %d", got) + } +} + func TestRedactToken(t *testing.T) { cases := []struct{ in, want string }{ {"", "****"}, From 4e641d1d9d1a7d06589fa71c42a71183dc46158b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 13 May 2026 07:03:16 +0000 Subject: [PATCH 3/5] ci: add `ci install` to npm-install agent CLIs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New subcommand wraps `npm install -g [@version]` for the three supported agents. Package mapping lives in AgentInfo.NPMPackage so each agent owns its own canonical name; --package and --version flags allow overrides for forks and version pinning. Sudo is intentionally not handled — npm's own EACCES message is more informative. --- ai/agent/info.go | 13 +++-- internal/command/ci.go | 3 +- internal/command/ci_install.go | 84 +++++++++++++++++++++++++++++ internal/command/ci_install_test.go | 81 ++++++++++++++++++++++++++++ 4 files changed, 177 insertions(+), 4 deletions(-) create mode 100644 internal/command/ci_install.go create mode 100644 internal/command/ci_install_test.go diff --git a/ai/agent/info.go b/ai/agent/info.go index af93a85da..6d883e05f 100644 --- a/ai/agent/info.go +++ b/ai/agent/info.go @@ -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 @@ -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, @@ -38,7 +43,8 @@ func ListAgentInfo() []AgentInfo { ConfigFiles: []string{ "~/.config/opencode/opencode.json", }, - Scenario: "opencode", + Scenario: "opencode", + NPMPackage: "opencode-ai", }, { Type: AgentTypeCodex, @@ -48,7 +54,8 @@ func ListAgentInfo() []AgentInfo { "~/.codex/config.toml", "~/.codex/auth.json", }, - Scenario: "codex", + Scenario: "codex", + NPMPackage: "@openai/codex", }, } } diff --git a/internal/command/ci.go b/internal/command/ci.go index 1fd18bf3b..087d95a0a 100644 --- a/internal/command/ci.go +++ b/internal/command/ci.go @@ -20,7 +20,8 @@ import ( // `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'"` + 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 diff --git a/internal/command/ci_install.go b/internal/command/ci_install.go new file mode 100644 index 000000000..1c75c0d1c --- /dev/null +++ b/internal/command/ci_install.go @@ -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 "[@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 +} diff --git a/internal/command/ci_install_test.go b/internal/command/ci_install_test.go new file mode 100644 index 000000000..b57c10e73 --- /dev/null +++ b/internal/command/ci_install_test.go @@ -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) + } +} From 7a133fbdf88b846bca13a93d35519416d88ce3d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 13 May 2026 07:29:37 +0000 Subject: [PATCH 4/5] script: add install.sh and install-mirror.sh bootstraps install.sh fetches nvm, installs LTS Node, then `npm install -g tingly-box` from defaults. install-mirror.sh routes the same flow through gh-proxy (github), npmmirror node binaries, and registry.npmmirror.com so users on restricted networks can finish the bootstrap without manual mirror config. Both scripts are idempotent: re-running detects an existing nvm install and skips re-fetching it. Mirror endpoints are env-overridable. Unignore the script/ directory so these (and future) helpers are tracked. --- .gitignore | 1 - script/install-mirror.sh | 72 ++++++++++++++++++++++++++++++++++++++++ script/install.sh | 57 +++++++++++++++++++++++++++++++ 3 files changed, 129 insertions(+), 1 deletion(-) create mode 100755 script/install-mirror.sh create mode 100755 script/install.sh diff --git a/.gitignore b/.gitignore index 9f2b8a7e9..99b6be9a6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,5 @@ # doc docs -script .worktrees # codegen diff --git a/script/install-mirror.sh b/script/install-mirror.sh new file mode 100755 index 000000000..095abc1f3 --- /dev/null +++ b/script/install-mirror.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# Mirror-aware variant of install.sh for mainland China networks. +# +# Mirrors used (override via env vars): +# GH_PROXY github.com / raw.githubusercontent.com proxy +# NODE_MIRROR nvm's NVM_NODEJS_ORG_MIRROR target +# NPM_REGISTRY npm registry +# +# Usage: +# bash script/install-mirror.sh + +set -euo pipefail + +NVM_VERSION="${NVM_VERSION:-v0.40.1}" +NODE_VERSION="${NODE_VERSION:---lts}" +GH_PROXY="${GH_PROXY:-https://gh-proxy.com}" +NODE_MIRROR="${NODE_MIRROR:-https://npmmirror.com/mirrors/node/}" +NPM_REGISTRY="${NPM_REGISTRY:-https://registry.npmmirror.com}" + +log() { printf '\033[1;34m[install-mirror]\033[0m %s\n' "$*"; } +err() { printf '\033[1;31m[install-mirror]\033[0m %s\n' "$*" >&2; } + +if [ -z "${BASH_VERSION:-}" ]; then + err "this script requires bash; re-run with: bash $0" + exit 2 +fi + +install_nvm() { + if [ -s "${NVM_DIR:-$HOME/.nvm}/nvm.sh" ]; then + log "nvm already installed at ${NVM_DIR:-$HOME/.nvm}, skipping" + return + fi + log "installing nvm ${NVM_VERSION} via ${GH_PROXY}" + # nvm's installer fetches its own files from raw.githubusercontent.com via + # NVM_SOURCE; route those through the same proxy so both the bootstrap and + # the per-file fetch succeed in restricted networks. + local installer="${GH_PROXY}/https://raw.githubusercontent.com/nvm-sh/nvm/${NVM_VERSION}/install.sh" + NVM_SOURCE="${GH_PROXY}/https://github.com/nvm-sh/nvm.git" \ + curl -fsSL "$installer" | bash +} + +load_nvm() { + export NVM_DIR="${NVM_DIR:-$HOME/.nvm}" + # shellcheck disable=SC1091 + . "$NVM_DIR/nvm.sh" +} + +install_node() { + log "installing node (${NODE_VERSION}) from ${NODE_MIRROR}" + NVM_NODEJS_ORG_MIRROR="$NODE_MIRROR" nvm install "$NODE_VERSION" + nvm use "$NODE_VERSION" >/dev/null + log "node $(node -v) / npm $(npm -v)" +} + +configure_npm_registry() { + log "setting npm registry to ${NPM_REGISTRY}" + npm config set registry "$NPM_REGISTRY" +} + +install_tingly_box() { + log "installing tingly-box via npm (registry=${NPM_REGISTRY})" + npm install -g tingly-box + log "installed: $(tingly-box version 2>/dev/null || echo 'tingly-box')" +} + +install_nvm +load_nvm +install_node +configure_npm_registry +install_tingly_box + +log "done. open a new shell or run: . \"\$NVM_DIR/nvm.sh\"" diff --git a/script/install.sh b/script/install.sh new file mode 100755 index 000000000..3781d7c4c --- /dev/null +++ b/script/install.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# Install nvm + LTS Node + tingly-box from official sources. +# For mainland China users, prefer install-mirror.sh. +# +# Usage: +# curl -fsSL https://raw.githubusercontent.com/tingly-dev/tingly-box/main/script/install.sh | bash +# bash script/install.sh + +set -euo pipefail + +NVM_VERSION="${NVM_VERSION:-v0.40.1}" +NODE_VERSION="${NODE_VERSION:---lts}" + +log() { printf '\033[1;34m[install]\033[0m %s\n' "$*"; } +err() { printf '\033[1;31m[install]\033[0m %s\n' "$*" >&2; } + +# nvm needs bash/zsh; refuse plain sh so users get a clear message rather +# than a confusing array-syntax error 100 lines into the install. +if [ -z "${BASH_VERSION:-}" ]; then + err "this script requires bash; re-run with: bash $0" + exit 2 +fi + +install_nvm() { + if [ -s "${NVM_DIR:-$HOME/.nvm}/nvm.sh" ]; then + log "nvm already installed at ${NVM_DIR:-$HOME/.nvm}, skipping" + return + fi + log "installing nvm ${NVM_VERSION}" + curl -fsSL "https://raw.githubusercontent.com/nvm-sh/nvm/${NVM_VERSION}/install.sh" | bash +} + +load_nvm() { + export NVM_DIR="${NVM_DIR:-$HOME/.nvm}" + # shellcheck disable=SC1091 + . "$NVM_DIR/nvm.sh" +} + +install_node() { + log "installing node (${NODE_VERSION})" + nvm install "$NODE_VERSION" + nvm use "$NODE_VERSION" >/dev/null + log "node $(node -v) / npm $(npm -v)" +} + +install_tingly_box() { + log "installing tingly-box via npm" + npm install -g tingly-box + log "installed: $(tingly-box version 2>/dev/null || echo 'tingly-box')" +} + +install_nvm +load_nvm +install_node +install_tingly_box + +log "done. open a new shell or run: . \"\$NVM_DIR/nvm.sh\"" From 4d3152b432c26982bc21195d79b7999109102a0b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 13 May 2026 07:38:41 +0000 Subject: [PATCH 5/5] script: switch install-mirror.sh nvm source to gitee MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the gh-proxy hop for nvm bootstrap and clone directly from gitee.com/mirrors/nvm — fewer moving parts, more reliable inside CN. Skip the upstream install.sh entirely; replicate its profile-snippet append manually (idempotent grep) so subsequent shells still pick up nvm. Node binaries and the npm registry continue to use Taobao (npmmirror.com), which was already the default. --- script/install-mirror.sh | 57 ++++++++++++++++++++++++++++------------ 1 file changed, 40 insertions(+), 17 deletions(-) diff --git a/script/install-mirror.sh b/script/install-mirror.sh index 095abc1f3..53bb1c242 100755 --- a/script/install-mirror.sh +++ b/script/install-mirror.sh @@ -1,22 +1,28 @@ #!/usr/bin/env bash # Mirror-aware variant of install.sh for mainland China networks. # -# Mirrors used (override via env vars): -# GH_PROXY github.com / raw.githubusercontent.com proxy -# NODE_MIRROR nvm's NVM_NODEJS_ORG_MIRROR target -# NPM_REGISTRY npm registry +# Defaults: +# nvm cloned from gitee.com/mirrors/nvm +# node fetched from npmmirror.com (Taobao) +# npm reg registry.npmmirror.com (Taobao) # -# Usage: -# bash script/install-mirror.sh +# Override via env vars: +# NVM_GITEE_REPO git URL for the nvm mirror +# NVM_VERSION nvm tag/branch to checkout +# NODE_VERSION node version (e.g. 20, --lts) +# NODE_MIRROR NVM_NODEJS_ORG_MIRROR target +# NPM_REGISTRY npm registry to write into ~/.npmrc set -euo pipefail NVM_VERSION="${NVM_VERSION:-v0.40.1}" +NVM_GITEE_REPO="${NVM_GITEE_REPO:-https://gitee.com/mirrors/nvm.git}" NODE_VERSION="${NODE_VERSION:---lts}" -GH_PROXY="${GH_PROXY:-https://gh-proxy.com}" NODE_MIRROR="${NODE_MIRROR:-https://npmmirror.com/mirrors/node/}" NPM_REGISTRY="${NPM_REGISTRY:-https://registry.npmmirror.com}" +NVM_DIR="${NVM_DIR:-$HOME/.nvm}" + log() { printf '\033[1;34m[install-mirror]\033[0m %s\n' "$*"; } err() { printf '\033[1;31m[install-mirror]\033[0m %s\n' "$*" >&2; } @@ -26,21 +32,38 @@ if [ -z "${BASH_VERSION:-}" ]; then fi install_nvm() { - if [ -s "${NVM_DIR:-$HOME/.nvm}/nvm.sh" ]; then - log "nvm already installed at ${NVM_DIR:-$HOME/.nvm}, skipping" + if [ -s "$NVM_DIR/nvm.sh" ]; then + log "nvm already installed at $NVM_DIR, skipping" return fi - log "installing nvm ${NVM_VERSION} via ${GH_PROXY}" - # nvm's installer fetches its own files from raw.githubusercontent.com via - # NVM_SOURCE; route those through the same proxy so both the bootstrap and - # the per-file fetch succeed in restricted networks. - local installer="${GH_PROXY}/https://raw.githubusercontent.com/nvm-sh/nvm/${NVM_VERSION}/install.sh" - NVM_SOURCE="${GH_PROXY}/https://github.com/nvm-sh/nvm.git" \ - curl -fsSL "$installer" | bash + log "cloning nvm ${NVM_VERSION} from ${NVM_GITEE_REPO}" + git clone --depth 1 --branch "$NVM_VERSION" "$NVM_GITEE_REPO" "$NVM_DIR" + ensure_profile_snippet +} + +# ensure_profile_snippet appends NVM_DIR sourcing to the user's shell rc so +# subsequent shells pick up nvm. nvm's official install.sh does this — since +# we're cloning manually, we replicate it. Idempotent: grep before append. +ensure_profile_snippet() { + local snippet + snippet="$(cat <<'EOF' +# nvm (added by tingly-box install-mirror.sh) +export NVM_DIR="$HOME/.nvm" +[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" +[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion" +EOF + )" + for rc in "$HOME/.bashrc" "$HOME/.zshrc"; do + [ -f "$rc" ] || continue + if ! grep -q 'NVM_DIR="$HOME/.nvm"' "$rc"; then + log "appending nvm init to $rc" + printf '\n%s\n' "$snippet" >> "$rc" + fi + done } load_nvm() { - export NVM_DIR="${NVM_DIR:-$HOME/.nvm}" + export NVM_DIR # shellcheck disable=SC1091 . "$NVM_DIR/nvm.sh" }