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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
BINARY_NAME=pangolin
OUTPUT_DIR=bin

VERSION ?= 0.15.0
VERSION ?= 0.15.1
LDFLAGS = -s -w -X github.com/fosrl/cli/internal/version.Version=$(VERSION)

all: clean build
Expand Down
72 changes: 62 additions & 10 deletions cmd/apply/blueprint/blueprint.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import (

type BlueprintCmdOpts struct {
Name string
Path string
Paths []string
APIKey string
Endpoint string
OrgID string
Expand All @@ -29,7 +29,8 @@ func BlueprintCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "blueprint",
Short: "Apply a blueprint",
Long: "Apply a YAML blueprint to the Pangolin server",
Long: "Apply a YAML blueprint to the Pangolin server. --file may be a glob pattern (e.g. -f 'inference-*.yaml') or given multiple times; every matching file is applied one by one.",
Args: cobra.ArbitraryArgs,
PreRunE: func(cmd *cobra.Command, args []string) error {
// Integration API: any of the three flags implies all three are required (avoids silent session fallback).
integration := opts.APIKey != "" || opts.Endpoint != "" || opts.OrgID != ""
Expand All @@ -39,16 +40,29 @@ func BlueprintCmd() *cobra.Command {
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
if err := applyBlueprintMain(cmd, opts); err != nil {
// Extra positional args show up when the shell expands a glob (e.g. -f inference-*)
// before we ever see it; fold them in alongside anything passed via -f itself.
paths, err := resolveBlueprintPaths(append(append([]string{}, opts.Paths...), args...))
if err != nil {
return err
}
logger.Info("Successfully applied blueprint!")

if opts.Name != "" && len(paths) > 1 {
return errors.New("--name cannot be used when multiple blueprint files match; the name is derived from each filename instead")
}

for _, path := range paths {
if err := applyBlueprintMain(cmd, opts, path); err != nil {
return fmt.Errorf("%s: %w", path, err)
}
logger.Info("Successfully applied blueprint: %s", path)
}
return nil
},
}

cmd.Flags().StringVarP(&opts.Path, "file", "f", "", "Blueprint YAML file path (use '-' for stdin)")
cmd.Flags().StringVarP(&opts.Name, "name", "n", "", "Blueprint name (default: filename without extension)")
cmd.Flags().StringArrayVarP(&opts.Paths, "file", "f", nil, "Blueprint YAML file path, or glob pattern (e.g. 'inference-*.yaml'); repeatable. Use '-' for stdin")
cmd.Flags().StringVarP(&opts.Name, "name", "n", "", "Blueprint name (default: filename without extension); only valid for a single file")
cmd.Flags().StringVar(&opts.APIKey, "api-key", "", "Integration API key (id.secret)")
cmd.Flags().StringVar(&opts.Endpoint, "endpoint", "", "Integration API host URL")
cmd.Flags().StringVar(&opts.OrgID, "org", "", "Organization ID")
Expand All @@ -57,14 +71,52 @@ func BlueprintCmd() *cobra.Command {
return cmd
}

func applyBlueprintMain(cmd *cobra.Command, opts BlueprintCmdOpts) error {
if opts.Path == "-" && strings.TrimSpace(opts.Name) == "" {
// resolveBlueprintPaths expands any glob patterns among the given tokens into
// concrete file paths, passes "-" (stdin) and non-glob paths through as-is,
// and dedupes the result while preserving order.
func resolveBlueprintPaths(tokens []string) ([]string, error) {
seen := make(map[string]bool)
var resolved []string

add := func(path string) {
if !seen[path] {
seen[path] = true
resolved = append(resolved, path)
}
}

for _, token := range tokens {
if token == "-" || !strings.ContainsAny(token, "*?[") {
add(token)
continue
}

matches, err := filepath.Glob(token)
if err != nil {
return nil, fmt.Errorf("invalid glob pattern %q: %w", token, err)
}
if len(matches) == 0 {
return nil, fmt.Errorf("no files matched pattern %q", token)
}
for _, m := range matches {
add(m)
}
}

if len(resolved) == 0 {
return nil, errors.New("no blueprint files specified")
}
return resolved, nil
}

func applyBlueprintMain(cmd *cobra.Command, opts BlueprintCmdOpts, path string) error {
if path == "-" && strings.TrimSpace(opts.Name) == "" {
return errors.New("name is required when using --file -")
}

name := opts.Name
if name == "" {
filename := filepath.Base(opts.Path)
filename := filepath.Base(path)
switch ext := strings.ToLower(filepath.Ext(filename)); ext {
case ".yaml", ".yml":
name = strings.TrimSuffix(filename, ext)
Expand All @@ -79,7 +131,7 @@ func applyBlueprintMain(cmd *cobra.Command, opts BlueprintCmdOpts) error {
apiClient := api.FromContext(cmd.Context())
accountStore := config.AccountStoreFromContext(cmd.Context())

blueprintContents, err := readBlueprint(opts.Path)
blueprintContents, err := readBlueprint(path)
if err != nil {
return err
}
Expand Down
11 changes: 8 additions & 3 deletions cmd/auth/login/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,19 @@ func getDeviceName() string {
return hostname
}

func loginWithWeb(hostname string) (string, error) {
func loginWithWeb(hostname string, sessionCookieName string) (string, error) {
// Build base URL for login (use hostname as-is, StartDeviceWebAuth will add /api/v1)
baseURL := hostname

if sessionCookieName == "" {
sessionCookieName = "p_session_token"
}

// Create a temporary API client for login (without auth)
loginClient, err := api.NewClient(api.ClientConfig{
BaseURL: baseURL,
AgentName: "pangolin-cli",
SessionCookieName: "p_session_token",
SessionCookieName: sessionCookieName,
CSRFToken: "x-csrf-protection",
})
if err != nil {
Expand Down Expand Up @@ -182,6 +186,7 @@ func loginMain(cmd *cobra.Command, opts *LoginCmdOpts) error {

apiClient := api.FromContext(cmd.Context())
accountStore := config.AccountStoreFromContext(cmd.Context())
cfg := config.ConfigFromContext(cmd.Context())

hostname := opts.Hostname

Expand Down Expand Up @@ -237,7 +242,7 @@ func loginMain(cmd *cobra.Command, opts *LoginCmdOpts) error {
}

// Perform web login
sessionToken, err := loginWithWeb(hostname)
sessionToken, err := loginWithWeb(hostname, cfg.SessionCookieName)
if err != nil {
logger.Error("%v", err)
return err
Expand Down
4 changes: 4 additions & 0 deletions cmd/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,10 @@ func dumpConfig(cfg *config.Config) error {
"disable_companion_mode": cfg.DisableCompanionMode,
}

if cfg.SessionCookieName != "" {
out["session_cookie_name"] = cfg.SessionCookieName
}

up := map[string]any{}
if cfg.IsSet("up.tunnel_dns") {
up["tunnel_dns"] = cfg.GetBool("up.tunnel_dns")
Expand Down
83 changes: 83 additions & 0 deletions cmd/configure/claude.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package configure

import (
"fmt"
"path/filepath"
"runtime"
)

// writeClaudeConfig merges the Pangolin AI gateway settings into
// ~/.claude/settings.json, preserving any other existing keys.
func writeClaudeConfig(endpoint string, auth Auth) ([]string, error) {
home, err := homeDir()
if err != nil {
return nil, err
}
path := filepath.Join(home, ".claude", "settings.json")

m, err := readJSONMap(path)
if err != nil {
return nil, err
}

// Keyless resources still get a helper emitting a placeholder: a non-empty,
// unusable value forces Claude to invoke the helper at all instead of
// silently falling back to whatever account/key the user already has
// configured.
m["apiKeyHelper"] = apiKeyHelperEcho(keyOrPlaceholder(auth))

env := ensureMap(m, "env")
env["ANTHROPIC_BASE_URL"] = endpoint

if err := writeJSONMap(path, m); err != nil {
return nil, err
}

return []string{path}, nil
}

// apiKeyHelperEcho formats an `echo` command that prints value verbatim, in
// the shell Claude Code actually runs apiKeyHelper through on each platform.
// Claude Code runs it via cmd.exe on Windows (not PowerShell or Git Bash),
// and cmd.exe's echo doesn't strip quote characters at all - wrapping value
// in bash-style single quotes there would leak the quotes into the value
// Claude reads. POSIX shells (macOS/Linux) get single-quoted for safety
// against shell interpretation instead.
func apiKeyHelperEcho(value string) string {
if runtime.GOOS == "windows" {
return fmt.Sprintf("echo %s", value)
}
return fmt.Sprintf("echo '%s'", value)
}

// resetClaudeConfig removes the keys writeClaudeConfig sets from
// ~/.claude/settings.json, preserving every other existing key.
func resetClaudeConfig() ([]string, error) {
home, err := homeDir()
if err != nil {
return nil, err
}
path := filepath.Join(home, ".claude", "settings.json")

m, exists, err := readExistingJSONMap(path)
if err != nil || !exists {
return nil, err
}

changed := deleteKey(m, "apiKeyHelper")
if env, ok := m["env"].(map[string]interface{}); ok {
if deleteKey(env, "ANTHROPIC_BASE_URL") {
changed = true
}
pruneEmptyMap(m, "env")
}

if !changed {
return nil, nil
}
if err := writeJSONMap(path, m); err != nil {
return nil, err
}

return []string{path}, nil
}
Loading