Skip to content

Commit a5f6740

Browse files
websterclaude
andcommitted
feat(extension): implicit plugin mechanism via circleci-<name> PATH lookup
Unknown top-level commands transparently exec a matching circleci-<name> binary from PATH, following git's plugin convention. The extension receives CIRCLE_TOKEN, CIRCLE_URL, and best-effort project metadata (VCS type, org, repo) via environment variables so it can call the CircleCI API without reimplementing auth. Only top-level unknown commands are intercepted — unknown subcommands within a group (e.g. "circleci pipeline foo") still produce the normal cobra error. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 3a3a59b commit a5f6740

2 files changed

Lines changed: 183 additions & 0 deletions

File tree

internal/extension/extension.go

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
// Copyright (c) 2026 Circle Internet Services, Inc.
2+
//
3+
// Permission is hereby granted, free of charge, to any person obtaining a copy
4+
// of this software and associated documentation files (the "Software"), to deal
5+
// in the Software without restriction, including without limitation the rights
6+
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7+
// copies of the Software, and to permit persons to whom the Software is
8+
// furnished to do so, subject to the following conditions:
9+
//
10+
// The above copyright notice and this permission notice shall be included in
11+
// all copies or substantial portions of the Software.
12+
//
13+
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14+
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15+
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16+
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17+
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18+
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19+
// SOFTWARE.
20+
//
21+
// SPDX-License-Identifier: MIT
22+
23+
// Package extension implements the circleci plugin mechanism.
24+
//
25+
// Any executable named "circleci-<name>" found in PATH is treated as an
26+
// extension and can be invoked transparently as "circleci <name>". The
27+
// extension receives CIRCLE_TOKEN, CIRCLE_URL, and best-effort project
28+
// metadata via environment variables so it can call the CircleCI API without
29+
// reimplementing authentication.
30+
package extension
31+
32+
import (
33+
"context"
34+
"fmt"
35+
"os"
36+
"os/exec"
37+
"strings"
38+
39+
"github.com/CircleCI-Public/circleci-cli-v2/internal/config"
40+
"github.com/CircleCI-Public/circleci-cli-v2/internal/gitremote"
41+
)
42+
43+
// ErrNotFound is returned when no circleci-<name> binary exists in PATH.
44+
type ErrNotFound struct {
45+
Name string
46+
}
47+
48+
func (e *ErrNotFound) Error() string {
49+
return fmt.Sprintf("unknown command %q — and no extension %q found in PATH", e.Name, "circleci-"+e.Name)
50+
}
51+
52+
// Run looks up circleci-<name> in PATH and execs it with args, injecting
53+
// CircleCI environment variables. configPath is the --config flag value
54+
// (empty means use the default XDG path). The current process is replaced
55+
// by the extension via syscall exec on Unix; on Windows the extension is
56+
// run as a child process and its exit code is propagated.
57+
//
58+
// If no matching binary is found, ErrNotFound is returned and the caller
59+
// should show the original "unknown command" error instead.
60+
func Run(ctx context.Context, name string, args []string, configPath string) error {
61+
binary := "circleci-" + name
62+
path, err := exec.LookPath(binary)
63+
if err != nil {
64+
return &ErrNotFound{Name: name}
65+
}
66+
67+
env := buildEnv(ctx, configPath)
68+
69+
cmd := exec.CommandContext(ctx, path, args...) //#nosec:G204 // path comes from LookPath, args are user-supplied CLI args for the extension
70+
cmd.Stdin = os.Stdin
71+
cmd.Stdout = os.Stdout
72+
cmd.Stderr = os.Stderr
73+
cmd.Env = env
74+
75+
if err := cmd.Run(); err != nil {
76+
if cmd.ProcessState != nil {
77+
// Exit with the extension's exit code, not our own error message.
78+
os.Exit(cmd.ProcessState.ExitCode())
79+
}
80+
return fmt.Errorf("extension %q failed: %w", binary, err)
81+
}
82+
return nil
83+
}
84+
85+
// buildEnv constructs the environment for the extension process. It starts
86+
// from the current process environment and overlays CIRCLE_* variables so
87+
// extensions can call the CircleCI API without reimplementing auth.
88+
func buildEnv(ctx context.Context, configPath string) []string {
89+
env := os.Environ()
90+
91+
cfg, err := config.LoadFrom(ctx, configPath, false)
92+
if err != nil {
93+
cfg = &config.Config{}
94+
}
95+
96+
overlays := map[string]string{
97+
"CIRCLE_TOKEN": cfg.EffectiveToken(),
98+
"CIRCLE_URL": cfg.EffectiveHost(),
99+
}
100+
101+
// Best-effort: inject project metadata from git remote. Failures are
102+
// silently ignored — the extension is responsible for handling missing vars.
103+
if info, err := gitremote.Detect(); err == nil {
104+
parts := strings.SplitN(info.Slug, "/", 3)
105+
if len(parts) == 3 {
106+
overlays["CIRCLE_VCS_TYPE"] = vcsLong(parts[0])
107+
overlays["CIRCLE_PROJECT_USERNAME"] = parts[1]
108+
overlays["CIRCLE_PROJECT_REPONAME"] = parts[2]
109+
}
110+
}
111+
112+
for k, v := range overlays {
113+
if v != "" {
114+
env = append(env, k+"="+v)
115+
}
116+
}
117+
return env
118+
}
119+
120+
func vcsLong(short string) string {
121+
switch short {
122+
case "gh":
123+
return "github"
124+
case "bb":
125+
return "bitbucket"
126+
case "gl":
127+
return "gitlab"
128+
default:
129+
return short
130+
}
131+
}

main.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,12 @@ import (
2828
"fmt"
2929
"os"
3030
"os/signal"
31+
"strings"
3132
"syscall"
3233

3334
"github.com/CircleCI-Public/circleci-cli-v2/internal/cmd/root"
3435
clierrors "github.com/CircleCI-Public/circleci-cli-v2/internal/errors"
36+
"github.com/CircleCI-Public/circleci-cli-v2/internal/extension"
3537
)
3638

3739
var version = "dev"
@@ -51,6 +53,23 @@ func run() int {
5153
rootCmd := root.NewRootCmd(version)
5254
rootCmd.SetContext(ctx)
5355
if err := rootCmd.Execute(); err != nil {
56+
// Before reporting "unknown command", try the implicit extension mechanism:
57+
// any executable named "circleci-<name>" in PATH is a valid extension.
58+
// Only intercept top-level unknown commands (error ends with `for "circleci"`).
59+
if name, ok := rootUnknownCommand(err); ok {
60+
configPath, _ := rootCmd.Flags().GetString("config")
61+
extArgs := extensionArgs(name)
62+
extErr := extension.Run(ctx, name, extArgs, configPath)
63+
if extErr == nil {
64+
return clierrors.ExitSuccess
65+
}
66+
// Extension not found — fall through and show the original cobra error.
67+
if !errors.As(extErr, new(*extension.ErrNotFound)) {
68+
_, _ = fmt.Fprintln(os.Stderr, extErr)
69+
return clierrors.ExitGeneralError
70+
}
71+
}
72+
5473
if cliErr, ok := errors.AsType[*clierrors.CLIError](err); ok {
5574
if jsonFlagPresent() {
5675
_, _ = fmt.Fprint(os.Stderr, cliErr.FormatJSON())
@@ -65,6 +84,39 @@ func run() int {
6584
return clierrors.ExitSuccess
6685
}
6786

87+
// rootUnknownCommand reports whether err is Cobra's "unknown command" error
88+
// for the root command specifically (not a nested group). Returns the command
89+
// name that was not found.
90+
func rootUnknownCommand(err error) (string, bool) {
91+
msg := err.Error()
92+
// Cobra formats this as: unknown command "foo" for "circleci"
93+
const prefix = `unknown command "`
94+
if !strings.HasPrefix(msg, prefix) {
95+
return "", false
96+
}
97+
// Must be for the root, not a subgroup like "circleci pipeline".
98+
if !strings.HasSuffix(msg, `for "circleci"`) {
99+
return "", false
100+
}
101+
name, _, ok := strings.Cut(msg[len(prefix):], `"`)
102+
if !ok {
103+
return "", false
104+
}
105+
return name, true
106+
}
107+
108+
// extensionArgs returns the arguments to pass to the extension binary.
109+
// It scans os.Args for the first non-flag positional argument matching name
110+
// and returns everything after it.
111+
func extensionArgs(name string) []string {
112+
for i, arg := range os.Args[1:] {
113+
if !strings.HasPrefix(arg, "-") && arg == name {
114+
return os.Args[i+2:]
115+
}
116+
}
117+
return nil
118+
}
119+
68120
// jsonFlagPresent reports whether --json appears anywhere in the raw argument
69121
// list. This is intentionally a simple scan rather than full flag parsing —
70122
// we only need it to format errors before Cobra has had a chance to run.

0 commit comments

Comments
 (0)