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
194 changes: 180 additions & 14 deletions internal/cloud/request.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import (
const (
envIdentityName = "IDENTITY_NAME"
envOpenClawServiceMarker = "OPENCLAW_SERVICE_MARKER"
envTermProgram = "TERM_PROGRAM"
envTerminalEmulator = "TERMINAL_EMULATOR"
)

func resolveHeaderValue(envName string, configValue string, fallback string) string {
Expand All @@ -41,24 +43,188 @@ func resolveSurface(configSurface string) string {
}

func resolveRuntime(configRuntime string) string {
if value := strings.TrimSpace(os.Getenv(cliconfig.EnvRuntime)); value != "" {
return value
return resolveRuntimeFrom(configRuntime, os.Environ())
}

func resolveRuntimeFrom(configRuntime string, environ []string) string {
env := parseEnviron(environ)

if value := strings.TrimSpace(env[cliconfig.EnvRuntime]); value != "" {
return normalizeRuntime(value)
}
if value := strings.TrimSpace(configRuntime); value != "" {
return value
return normalizeRuntime(value)
}

workspace := detectWorkspaceRuntimeFrom(environ)
client := detectClientEnvFrom(environ)
if workspace != "" && client != "" {
if workspace == client {
return workspace
}
return workspace + "," + client
}
if workspace != "" {
return workspace
}
if client != "" {
return client
}

if hasNonEmptyEnvPrefix(env, "OPENCLAW_") {
return "openclaw"
}
identity := strings.TrimSpace(env[envIdentityName])
if identity != "" {
return identity
}
return "unknown"
}

// detectWorkspaceRuntimeFrom identifies an outer workspace/sandbox host. This is
// kept separate from detectClientEnvFrom so nested executions can report both
// layers, for example "openclaw,claude-code".
func detectWorkspaceRuntimeFrom(environ []string) string {
env := parseEnviron(environ)

if hasNonEmptyEnvPrefix(env, "OPENCLAW_") {
return "openclaw"
}
if hasNonEmptyEnvPrefix(env, "ARKCLAW_") ||
strings.Contains(strings.ToLower(strings.TrimSpace(env[envIdentityName])), "arkclaw") {
return "arkclaw"
}
if hasNonEmptyEnvPrefix(env, "HERMES_") {
return "hermes"
}
return ""
}

// detectClientEnvFrom identifies the calling IDE/Agent host from environment
// signals. Ordering matters: dedicated strong signals are checked before the
// shared TERM_PROGRAM=vscode signal, because VS Code forks (Cursor, Trae,
// Windsurf) all report TERM_PROGRAM=vscode and would otherwise be misdetected.
// Returns an empty string when no client signal is present.
func detectClientEnvFrom(environ []string) string {
env := parseEnviron(environ)

present := func(name string) bool { return strings.TrimSpace(env[name]) != "" }
containsValue := func(name string, needle string) bool {
return strings.Contains(strings.ToLower(strings.TrimSpace(env[name])), needle)
}

// First tier: strong, dedicated signals.
if strings.TrimSpace(env["CLAUDECODE"]) == "1" || present("CLAUDE_CODE_ENTRYPOINT") {
return "claude-code"
}
if hasNonEmptyEnvPrefix(env, "CODEX_") {
return "codex"
}
if strings.Contains(strings.ToLower(env[envTerminalEmulator]), "jetbrains") {
return "jetbrains"
}
if strings.EqualFold(strings.TrimSpace(env[envTermProgram]), "WarpTerminal") {
return "warp"
}

parts := make([]string, 0, 2)
for _, envName := range []string{envIdentityName, envOpenClawServiceMarker} {
if value := strings.TrimSpace(os.Getenv(envName)); value != "" {
parts = append(parts, value)
// Second tier: VS Code fork combination check. Dedicated prefixes win over
// the shared TERM_PROGRAM=vscode fallback.
if present("CURSOR_TRACE_ID") || hasNonEmptyEnvPrefix(env, "CURSOR_") {
return "cursor"
}
if hasNonEmptyEnvPrefix(env, "TRAE_") {
return "trae"
}
if hasNonEmptyEnvPrefix(env, "WINDSURF_") ||
containsValue("VSCODE_GIT_ASKPASS_MAIN", "windsurf") ||
containsValue("VSCODE_GIT_ASKPASS_NODE", "windsurf") ||
containsValue("__CFBundleIdentifier", "windsurf") {
return "windsurf"
}

term := strings.TrimSpace(env[envTermProgram])
if strings.EqualFold(term, "vscode") {
return "vscode"
}
if term != "" {
return strings.ToLower(term)
}

return ""
}

// normalizeRuntime maps a user-provided runtime identifier to its canonical
// form, mirroring the keyword matching in detectClientEnvFrom so that injected
// values (e.g. MEDIAKIT_RUNTIME=claude) produce the same result as auto-
// detection (claude-code).
func normalizeRuntime(value string) string {
lower := strings.ToLower(strings.TrimSpace(value))
if lower == "" {
return ""
}
if strings.Contains(lower, "claude") {
return "claude-code"
}
if strings.Contains(lower, "codex") {
return "codex"
}
if strings.Contains(lower, "openclaw") {
return "openclaw"
}
if strings.Contains(lower, "arkclaw") {
return "arkclaw"
}
if strings.Contains(lower, "jetbrains") {
return "jetbrains"
}
if strings.Contains(lower, "warp") {
return "warp"
}
if strings.Contains(lower, "cursor") {
return "cursor"
}
if strings.Contains(lower, "trae") {
return "trae"
}
if strings.Contains(lower, "windsurf") {
return "windsurf"
}
if strings.Contains(lower, "vscode") {
return "vscode"
}
return lower
}

func PreviewHeaders(surface string, runtime string) map[string]string {
return map[string]string{
"Accept": "application/json",
"Content-Type": "application/json",
"x-surface": resolveSurface(surface),
"X-Amk-Cli-Runtime": resolveRuntime(runtime),
"X-Amk-Task-Source": "cli",
"X-Amk-Cli-Version": build.Version,
}
}

func parseEnviron(environ []string) map[string]string {
env := make(map[string]string, len(environ))
for _, entry := range environ {
key, value, found := strings.Cut(entry, "=")
if !found {
continue
}
env[key] = value
}
return env
}

if len(parts) == 0 {
return "unknown"
func hasNonEmptyEnvPrefix(env map[string]string, prefix string) bool {
for key, value := range env {
if strings.HasPrefix(key, prefix) && strings.TrimSpace(value) != "" {
return true
}
}
return strings.Join(parts, "/")
return false
}

func (c *Client) newRequest(method string, path string, query map[string]any, body map[string]any) (*http.Request, error) {
Expand Down Expand Up @@ -87,11 +253,11 @@ func (c *Client) newRequest(method string, path string, query map[string]any, bo
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("x-surface", resolveSurface(c.Surface))
req.Header.Set("X-Amk-Task-Runtime", resolveRuntime(c.Runtime))
req.Header.Set("X-Amk-Task-Source", "cli")
req.Header.Set("X-Amk-Cli-Version", build.Version)
req.Header.Set("X-Amk-Cli-Runtime", resolveRuntime(c.Runtime))
req.Header.Set("X-Amk-Task-Source", "cli")
req.Header.Set("X-Amk-Cli-Version", build.Version)
if c.APIKey != "" {
req.Header.Set("Authorization", "Bearer "+c.APIKey)
}
return req, nil
}
}
97 changes: 96 additions & 1 deletion internal/commands/config.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
package commands

import (
"encoding/json"
"fmt"
"strings"

"github.com/spf13/cobra"

"mediakit-cli/internal/cloud"
cliconfig "mediakit-cli/internal/config"
)

Expand All @@ -16,6 +19,7 @@ func newConfigCmd() *cobra.Command {

cmd.AddCommand(newConfigSetCmd())
cmd.AddCommand(newConfigShowCmd())
cmd.AddCommand(newConfigHeadersCmd())
return cmd
}

Expand All @@ -26,6 +30,7 @@ func newConfigSetCmd() *cobra.Command {
}
cmd.AddCommand(newConfigSetModeCmd())
cmd.AddCommand(newConfigSetOutputPathCmd())
cmd.AddCommand(newConfigSetUpdateCheckCmd())
return cmd
}

Expand Down Expand Up @@ -83,6 +88,53 @@ func newConfigSetOutputPathCmd() *cobra.Command {
}
}

func newConfigSetUpdateCheckCmd() *cobra.Command {
return &cobra.Command{
Use: "update-check [on|off]",
Short: "Enable or disable the automatic version update check",
Long: "Enable or disable the automatic version update check. Default: on. The MEDIAKIT_DISABLE_UPDATE_CHECK env var overrides this setting.",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
disabled, err := parseUpdateCheckToggle(args[0])
if err != nil {
return err
}
home, homeErr := cliconfig.ResolveHomeDir()
if homeErr != nil {
return homeErr
}
cfg, loadErr := cliconfig.LoadConfig(home)
if loadErr != nil {
return loadErr
}
cfg.DisableUpdateCheck = disabled
if err = cliconfig.SaveConfig(home, cfg); err != nil {
return err
}
_, err = fmt.Fprintf(cmd.OutOrStdout(), "自动更新检查已%s\n配置文件:%s\n", updateCheckStateLabel(!disabled), cliconfig.ConfigFile(home))
return err
},
}
}

func parseUpdateCheckToggle(value string) (bool, error) {
switch strings.ToLower(strings.TrimSpace(value)) {
case "on", "true", "enable", "enabled", "1":
return false, nil
case "off", "false", "disable", "disabled", "0":
return true, nil
default:
return false, fmt.Errorf("invalid value %q: expected on or off", value)
}
}

func updateCheckStateLabel(enabled bool) string {
if enabled {
return "开启"
}
return "关闭"
}

func newConfigShowCmd() *cobra.Command {
return &cobra.Command{
Use: "show",
Expand All @@ -102,7 +154,7 @@ func newConfigShowCmd() *cobra.Command {
}
_, err := fmt.Fprintf(
cmd.OutOrStdout(),
"当前配置\n- mode: %s\n- api_key: %s (%s)\n- endpoint: %s (%s)\n- output_path: %s (%s)\n- credential_store: %s\n- config_file: %s\n- env_cache: %s\n- last_env_check: %s\n",
"当前配置\n- mode: %s\n- api_key: %s (%s)\n- endpoint: %s (%s)\n- output_path: %s (%s)\n- credential_store: %s\n- update_check: %s\n- config_file: %s\n- env_cache: %s\n- last_env_check: %s\n",
resolved.Mode,
displaySecret(resolved.APIKey),
resolved.APIKeySource,
Expand All @@ -111,6 +163,7 @@ func newConfigShowCmd() *cobra.Command {
resolved.OutputPath,
resolved.OutputPathSource,
resolved.CredentialStore,
updateCheckStateLabel(!resolved.DisableUpdateCheck),
resolved.ConfigPath,
resolved.EnvCachePath,
displayValueOrFallback(cache.CheckedAt, "never"),
Expand All @@ -120,6 +173,48 @@ func newConfigShowCmd() *cobra.Command {
}
}

func newConfigHeadersCmd() *cobra.Command {
var asJSON bool
cmd := &cobra.Command{
Use: "headers",
Short: "Show locally resolved cloud request headers",
Long: "Show locally resolved non-sensitive cloud request headers. This command does not send a request and never prints Authorization.",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
home, resolveErr := cliconfig.ResolveHomeDir()
if resolveErr != nil {
return resolveErr
}
resolved, configErr := cliconfig.ResolveConfig(home)
if configErr != nil {
return configErr
}
headers := cloud.PreviewHeaders(resolved.Surface, resolved.Runtime)
if asJSON {
encoder := json.NewEncoder(cmd.OutOrStdout())
encoder.SetEscapeHTML(false)
encoder.SetIndent("", " ")
return encoder.Encode(map[string]any{
"headers": headers,
"config_file": resolved.ConfigPath,
})
}
if _, err := fmt.Fprintf(cmd.OutOrStdout(), "本地请求 Header(未发送请求,不包含 Authorization)\n"); err != nil {
return err
}
for _, key := range []string{"Accept", "Content-Type", "x-surface", "X-Amk-Cli-Runtime", "X-Amk-Task-Source", "X-Amk-Cli-Version"} {
if _, err := fmt.Fprintf(cmd.OutOrStdout(), "- %s: %s\n", key, headers[key]); err != nil {
return err
}
}
_, err := fmt.Fprintf(cmd.OutOrStdout(), "配置文件:%s\n", resolved.ConfigPath)
return err
},
}
cmd.Flags().BoolVar(&asJSON, "json", false, "Output structured JSON")
return cmd
}

func displaySecret(value string) string {
if value == "" {
return "<not configured>"
Expand Down
12 changes: 12 additions & 0 deletions internal/commands/root_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package commands

import "testing"

func TestRootCommandDoesNotExposeSkillsCommand(t *testing.T) {
cmd := newRootCmd()
for _, child := range cmd.Commands() {
if child.Name() == "skills" {
t.Fatal("root command exposes deprecated skills command")
}
}
}
Loading
Loading