From b8dde172f9900ca2b29995a659585eab0daa219a Mon Sep 17 00:00:00 2001 From: Aarav Maloo Date: Mon, 16 Mar 2026 19:20:12 +0530 Subject: [PATCH 1/6] create apminject parser --- internal/inject/apminject.go | 83 ++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 internal/inject/apminject.go diff --git a/internal/inject/apminject.go b/internal/inject/apminject.go new file mode 100644 index 0000000..b056ddd --- /dev/null +++ b/internal/inject/apminject.go @@ -0,0 +1,83 @@ +package inject + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" +) + +var ErrAPMInjectNotFound = errors.New(".apminject not found") + +type InjectMapping struct { + Entry string `yaml:"entry"` + As string `yaml:"as"` +} + +func FindAPMInjectFile(startDir string) (string, error) { + if startDir == "" { + return "", fmt.Errorf("start directory is empty") + } + + dir := startDir + for { + candidate := filepath.Join(dir, ".apminject") + info, err := os.Stat(candidate) + if err == nil && !info.IsDir() { + return candidate, nil + } + + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + + return "", ErrAPMInjectNotFound +} + +func ParseAPMInjectFile(path string) ([]InjectMapping, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + var mappings []InjectMapping + if err := yaml.Unmarshal(data, &mappings); err == nil && len(mappings) > 0 { + return sanitizeMappings(mappings), nil + } + + var wrapped struct { + Entries []InjectMapping `yaml:"entries"` + Inject []InjectMapping `yaml:"inject"` + } + if err := yaml.Unmarshal(data, &wrapped); err != nil { + return nil, err + } + + if len(wrapped.Entries) == 0 && len(wrapped.Inject) == 0 { + return nil, fmt.Errorf("no inject entries found in %s", path) + } + + if len(wrapped.Entries) > 0 { + return sanitizeMappings(wrapped.Entries), nil + } + return sanitizeMappings(wrapped.Inject), nil +} + +func sanitizeMappings(mappings []InjectMapping) []InjectMapping { + out := make([]InjectMapping, 0, len(mappings)) + for _, m := range mappings { + m.Entry = strings.TrimSpace(m.Entry) + m.As = strings.TrimSpace(m.As) + if m.Entry == "" { + continue + } + out = append(out, m) + } + return out +} From 29afcf60e20ff043caae65dc9d73470a734336b0 Mon Sep 17 00:00:00 2001 From: Aarav Maloo Date: Mon, 16 Mar 2026 19:20:24 +0530 Subject: [PATCH 2/6] create session handling for inject --- internal/inject/session.go | 162 +++++++++++++++++++++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 internal/inject/session.go diff --git a/internal/inject/session.go b/internal/inject/session.go new file mode 100644 index 0000000..650b078 --- /dev/null +++ b/internal/inject/session.go @@ -0,0 +1,162 @@ +package inject + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "time" +) + +var ErrSessionNotFound = errors.New("inject session not found") + +type InjectionSession struct { + ID string `json:"id"` + VarNames []string `json:"var_names"` + InjectedAt time.Time `json:"injected_at"` + ShellPID int `json:"shell_pid"` +} + +func StartSession(entries []ResolvedEntry, shell string) (string, error) { + if len(entries) == 0 { + return "", errors.New("no entries to inject") + } + + sessionID, err := generateSessionID() + if err != nil { + return "", err + } + + varNames := make([]string, 0, len(entries)) + for _, e := range entries { + if e.EnvVarName == "" { + continue + } + varNames = append(varNames, e.EnvVarName) + } + + session := &InjectionSession{ + ID: sessionID, + VarNames: varNames, + InjectedAt: time.Now(), + ShellPID: os.Getppid(), + } + + if err := WriteSession(session); err != nil { + return "", err + } + + detected, _ := DetectShellFromEnv(shell) + eval := WriteExports(entries, sessionID, detected) + + for i := range entries { + zeroBytes(entries[i].Value) + entries[i].Value = nil + } + + return eval, nil +} + +func KillSession() (string, error) { + session, err := ReadSession() + if err != nil { + return "", err + } + + varNames := make([]string, 0, len(session.VarNames)+1) + seen := make(map[string]struct{}) + for _, name := range session.VarNames { + if name == "" { + continue + } + if _, ok := seen[name]; ok { + continue + } + seen[name] = struct{}{} + varNames = append(varNames, name) + } + if _, ok := seen["APM_INJECT_SESSION"]; !ok { + varNames = append(varNames, "APM_INJECT_SESSION") + } + + eval := WriteUnsets(varNames, DetectShell()) + + if err := ClearSession(); err != nil { + return "", err + } + + return eval, nil +} + +func ReadSession() (*InjectionSession, error) { + path, err := sessionFilePath() + if err != nil { + return nil, err + } + + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, ErrSessionNotFound + } + return nil, err + } + + var session InjectionSession + if err := json.Unmarshal(data, &session); err != nil { + return nil, err + } + + return &session, nil +} + +func WriteSession(s *InjectionSession) error { + path, err := sessionFilePath() + if err != nil { + return err + } + + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + return err + } + + data, err := json.Marshal(s) + if err != nil { + return err + } + + return os.WriteFile(path, data, 0600) +} + +func ClearSession() error { + path, err := sessionFilePath() + if err != nil { + return err + } + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return err + } + return nil +} + +func sessionFilePath() (string, error) { + if dir := os.Getenv("APM_DATA_DIR"); dir != "" { + return filepath.Join(dir, "inject_session"), nil + } + configDir, err := os.UserConfigDir() + if err != nil { + return "", err + } + return filepath.Join(configDir, "apm", "inject_session"), nil +} + +func generateSessionID() (string, error) { + b := make([]byte, 8) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("failed to generate session id: %w", err) + } + return hex.EncodeToString(b), nil +} From c55266613fcf6fc3df1042e93dbc94ddf1f3cd9f Mon Sep 17 00:00:00 2001 From: Aarav Maloo Date: Mon, 16 Mar 2026 19:20:54 +0530 Subject: [PATCH 3/6] update main.go added ability to paste while writing notes and added `inject` command cli accessibility --- main.go | 35 +++++++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/main.go b/main.go index b737b39..0a5b4f0 100644 --- a/main.go +++ b/main.go @@ -26,6 +26,7 @@ import ( "context" + injectcmd "github.com/aaravmaloo/apm/cmd" src "github.com/aaravmaloo/apm/src" "github.com/aaravmaloo/apm/src/autofill" "github.com/aaravmaloo/apm/src/autofillcmd" @@ -34,6 +35,7 @@ import ( "github.com/aaravmaloo/apm/src/tui" "github.com/AlecAivazis/survey/v2" + "github.com/atotto/clipboard" "github.com/fatih/color" "github.com/fsnotify/fsnotify" "github.com/spf13/cobra" @@ -3401,7 +3403,7 @@ func main() { } return filepath.Join(filepath.Dir(vaultPath), "faceid", "models") }) - rootCmd.AddCommand(addCmd, getCmd, genCmd, modeCmd, sessionCmd, cinfoCmd, auditCmd, trustCmd, totpCmd, importCmd, exportCmd, infoCmd, cloudCmd, healthCmd, policyCmd, spaceCmd, pluginsCmd, setupCmd, unlockCmd, lockCmd, profileCmd, compromiseCmd, authCmd, vocabCmd, loadedCmd, faceidCmd) + rootCmd.AddCommand(addCmd, getCmd, genCmd, modeCmd, sessionCmd, cinfoCmd, auditCmd, trustCmd, totpCmd, importCmd, exportCmd, infoCmd, cloudCmd, healthCmd, policyCmd, spaceCmd, pluginsCmd, setupCmd, unlockCmd, lockCmd, profileCmd, compromiseCmd, authCmd, vocabCmd, loadedCmd, faceidCmd, injectcmd.BuildInjectCmd(src_unlockVault)) autofillCmd, _ := autofillcmd.NewAutofillAndVaultCommands(autofillcmd.Options{ VaultPath: &vaultPath, ReadPassword: readPassword, @@ -4042,6 +4044,7 @@ func captureNoteContent(vault *src.Vault, title, initial string) (string, error) buffer := []rune(initial) cursor := len(buffer) dismissedSuggestion := "" + statusMsg := "" type suggestionState struct { prefix string @@ -4122,8 +4125,11 @@ func captureNoteContent(vault *src.Vault, title, initial string) (string, error) render := func(s suggestionState) { fmt.Print("\033[H\033[2J") fmt.Printf("APM Note Editor - %s\n", title) - fmt.Println("Ctrl+S save | Esc cancel | Right accept suggestion | Left dismiss suggestion") + fmt.Println("Ctrl+S save | Ctrl+V paste | Esc cancel | Right accept suggestion | Left dismiss suggestion") fmt.Println("Auto: autocorrect, bracket pair (), alias replacement on space") + if statusMsg != "" { + fmt.Println(statusMsg) + } fmt.Println("--------------------------------------------------------------") ghostSuffix := "" @@ -4197,11 +4203,27 @@ func captureNoteContent(vault *src.Vault, title, initial string) (string, error) switch ch { case 19: return string(buffer), nil + case 22: + clip, err := clipboard.ReadAll() + if err != nil { + statusMsg = fmt.Sprintf("Clipboard read failed: %v", err) + break + } + if strings.TrimSpace(clip) == "" { + statusMsg = "Clipboard is empty" + break + } + clip = strings.ReplaceAll(clip, "\r\n", "\n") + clip = strings.ReplaceAll(clip, "\r", "\n") + insertRunes([]rune(clip)) + dismissedSuggestion = "" + statusMsg = fmt.Sprintf("Pasted %d chars from clipboard", len([]rune(clip))) case 27: return "", fmt.Errorf("note edit cancelled") case 127, 8: deleteBeforeCursor() dismissedSuggestion = "" + statusMsg = "" case '\r', '\n': if cursor < len(buffer) && isNoteWordRune(buffer[cursor]) { for cursor < len(buffer) && isNoteWordRune(buffer[cursor]) { @@ -4210,24 +4232,29 @@ func captureNoteContent(vault *src.Vault, title, initial string) (string, error) } insertRunes([]rune{'\n'}) dismissedSuggestion = "" + statusMsg = "" case ' ': applyWordTransforms() insertRunes([]rune{' '}) dismissedSuggestion = "" + statusMsg = "" case '(': insertRunes([]rune{'(', ')'}) cursor-- dismissedSuggestion = "" + statusMsg = "" case ')': if cursor < len(buffer) && buffer[cursor] == ')' { cursor++ } else { insertRunes([]rune{')'}) } + statusMsg = "" default: if ch >= 32 && ch <= 126 { insertRunes([]rune{rune(ch)}) dismissedSuggestion = "" + statusMsg = "" } } continue @@ -4245,6 +4272,7 @@ func captureNoteContent(vault *src.Vault, title, initial string) (string, error) } else if cursor < len(buffer) { cursor++ } + statusMsg = "" case strings.Contains(seq, "\x1b[D"): if s.word != "" { _ = vault.RecordNoteSuggestionFeedback(s.word, false) @@ -4252,9 +4280,12 @@ func captureNoteContent(vault *src.Vault, title, initial string) (string, error) } else if cursor > 0 { cursor-- } + statusMsg = "" case strings.Contains(seq, "\x1b[A"): + statusMsg = "" case strings.Contains(seq, "\x1b[B"): + statusMsg = "" } } From 5d68b30df4294f000d364513e7ad8fc4e3b5ffbf Mon Sep 17 00:00:00 2001 From: Aarav Maloo Date: Mon, 16 Mar 2026 19:21:06 +0530 Subject: [PATCH 4/6] create shellwriter to write to shell for inject command --- internal/inject/shellwriter.go | 164 +++++++++++++++++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 internal/inject/shellwriter.go diff --git a/internal/inject/shellwriter.go b/internal/inject/shellwriter.go new file mode 100644 index 0000000..33a7b26 --- /dev/null +++ b/internal/inject/shellwriter.go @@ -0,0 +1,164 @@ +package inject + +import ( + "bytes" + "os" + "runtime" + "strings" +) + +type Shell int + +const ( + Bash Shell = iota + Zsh + Fish + PowerShell +) + +func DetectShell() Shell { + shell, ok := DetectShellFromEnv(os.Getenv("SHELL")) + if ok { + return shell + } + if runtime.GOOS == "windows" && os.Getenv("PSModulePath") != "" { + return PowerShell + } + return Bash +} + +func DetectShellFromEnv(shellEnv string) (Shell, bool) { + s := strings.ToLower(strings.TrimSpace(shellEnv)) + switch { + case strings.Contains(s, "fish"): + return Fish, true + case strings.Contains(s, "zsh"): + return Zsh, true + case strings.Contains(s, "bash"): + return Bash, true + case strings.Contains(s, "pwsh") || strings.Contains(s, "powershell"): + return PowerShell, true + default: + return Bash, false + } +} + +func WriteExports(entries []ResolvedEntry, sessionID string, shell Shell) string { + var buf bytes.Buffer + for i := range entries { + e := entries[i] + if strings.TrimSpace(e.EnvVarName) == "" { + continue + } + writeExportLine(&buf, e.EnvVarName, e.Value, shell) + } + if strings.TrimSpace(sessionID) != "" { + writeExportLine(&buf, "APM_INJECT_SESSION", []byte(sessionID), shell) + } + + out := buf.String() + zeroBytes(buf.Bytes()) + return out +} + +func WriteUnsets(varNames []string, shell Shell) string { + var buf bytes.Buffer + for _, name := range varNames { + name = strings.TrimSpace(name) + if name == "" { + continue + } + switch shell { + case Fish: + buf.WriteString("set -e ") + buf.WriteString(name) + buf.WriteString(";\n") + case PowerShell: + buf.WriteString("Remove-Item Env:") + buf.WriteString(name) + buf.WriteString(" -ErrorAction SilentlyContinue;\n") + case Zsh, Bash: + fallthrough + default: + buf.WriteString("unset ") + buf.WriteString(name) + buf.WriteString(";\n") + } + } + + out := buf.String() + zeroBytes(buf.Bytes()) + return out +} + +func writeExportLine(buf *bytes.Buffer, name string, value []byte, shell Shell) { + switch shell { + case Fish: + buf.WriteString("set -x ") + buf.WriteString(name) + buf.WriteByte(' ') + escaped := escapeDoubleQuoted(value, shell) + buf.Write(escaped) + zeroBytes(escaped) + buf.WriteString(";\n") + case PowerShell: + buf.WriteString("$env:") + buf.WriteString(name) + buf.WriteString(" = ") + escaped := escapeSingleQuoted(value, shell) + buf.Write(escaped) + zeroBytes(escaped) + buf.WriteString(";\n") + case Zsh, Bash: + fallthrough + default: + buf.WriteString("export ") + buf.WriteString(name) + buf.WriteByte('=') + escaped := escapeSingleQuoted(value, shell) + buf.Write(escaped) + zeroBytes(escaped) + buf.WriteString(";\n") + } +} + +func escapeSingleQuoted(value []byte, shell Shell) []byte { + if len(value) == 0 { + return []byte("''") + } + out := make([]byte, 0, len(value)+2) + out = append(out, '\'') + for _, b := range value { + if b == '\'' { + out = append(out, '\'', '\\', '\'', '\'') + continue + } + out = append(out, b) + } + out = append(out, '\'') + return out +} + +func escapeDoubleQuoted(value []byte, shell Shell) []byte { + if len(value) == 0 { + return []byte("\"\"") + } + out := make([]byte, 0, len(value)+2) + out = append(out, '"') + for _, b := range value { + switch b { + case '\\', '"', '$', '`': + out = append(out, '\\', b) + default: + out = append(out, b) + } + } + out = append(out, '"') + return out +} + +func zeroBytes(b []byte) { + for i := range b { + b[i] = 0 + } +} From ecf968fd32cf90cfe7c20e19fc5174f2ad0111a4 Mon Sep 17 00:00:00 2001 From: Aarav Maloo Date: Mon, 16 Mar 2026 19:21:16 +0530 Subject: [PATCH 5/6] create resolver for inject --- internal/inject/resolver.go | 167 ++++++++++++++++++++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 internal/inject/resolver.go diff --git a/internal/inject/resolver.go b/internal/inject/resolver.go new file mode 100644 index 0000000..96a332c --- /dev/null +++ b/internal/inject/resolver.go @@ -0,0 +1,167 @@ +package inject + +import ( + "errors" + "fmt" + "strings" + "unicode" + + apm "github.com/aaravmaloo/apm/src" +) + +type ResolvedEntry struct { + EntryName string + EnvVarName string + Value []byte + Type string +} + +func ResolveEntries(vault *apm.Vault, names []string) ([]ResolvedEntry, error) { + if vault == nil { + return nil, errors.New("vault is nil") + } + + missing := make([]string, 0) + results := make([]ResolvedEntry, 0, len(names)) + + for _, raw := range names { + name := strings.TrimSpace(raw) + if name == "" { + continue + } + + resolved, ok := resolveByName(vault, name) + if !ok { + missing = append(missing, name) + continue + } + results = append(results, resolved) + } + + if len(missing) > 0 { + return nil, fmt.Errorf("entries not found: %s", strings.Join(missing, ", ")) + } + + return results, nil +} + +func ToEnvVarName(entryName string) string { + name := strings.TrimSpace(entryName) + if name == "" { + return "" + } + + var b strings.Builder + lastUnderscore := false + + for _, r := range name { + if unicode.IsLetter(r) || unicode.IsNumber(r) { + b.WriteRune(unicode.ToUpper(r)) + lastUnderscore = false + continue + } + if !lastUnderscore { + b.WriteByte('_') + lastUnderscore = true + } + } + + result := strings.Trim(b.String(), "_") + if result == "" { + return "" + } + if result[0] >= '0' && result[0] <= '9' { + result = "_" + result + } + return result +} + +func resolveByName(vault *apm.Vault, name string) (ResolvedEntry, bool) { + if e, ok := vault.GetEntry(name); ok { + return newResolved(name, "Password", e.Password), true + } + if e, ok := vault.GetTOTPEntry(name); ok { + return newResolved(name, "TOTP", e.Secret), true + } + if e, ok := vault.GetToken(name); ok { + return newResolved(name, "Token", e.Token), true + } + if e, ok := vault.GetSecureNote(name); ok { + return newResolved(name, "SecureNote", e.Content), true + } + if e, ok := vault.GetAPIKey(name); ok { + return newResolved(name, "APIKey", e.Key), true + } + if e, ok := vault.GetSSHKey(name); ok { + return newResolved(name, "SSHKey", e.PrivateKey), true + } + if e, ok := vault.GetWiFi(name); ok { + return newResolved(name, "WiFi", e.Password), true + } + if e, ok := vault.GetRecoveryCode(name); ok { + return newResolved(name, "RecoveryCodes", strings.Join(e.Codes, "\n")), true + } + if e, ok := vault.GetCertificate(name); ok { + return newResolved(name, "Certificate", e.PrivateKey), true + } + + if e, ok := resolveCloudCredential(vault, name); ok { + return e, true + } + if e, ok := resolveDockerRegistry(vault, name); ok { + return e, true + } + if e, ok := resolveSSHConfig(vault, name); ok { + return e, true + } + + return ResolvedEntry{}, false +} + +func newResolved(name, typ, value string) ResolvedEntry { + return ResolvedEntry{ + EntryName: name, + EnvVarName: ToEnvVarName(name), + Value: []byte(value), + Type: typ, + } +} + +func matchSpace(vault *apm.Vault, entrySpace string) bool { + current := vault.CurrentSpace + if current == "" { + current = "default" + } + target := entrySpace + if target == "" { + target = "default" + } + return current == target +} + +func resolveCloudCredential(vault *apm.Vault, name string) (ResolvedEntry, bool) { + for _, e := range vault.CloudCredentialsItems { + if e.Label == name && matchSpace(vault, e.Space) { + return newResolved(name, "CloudCredential", e.SecretKey), true + } + } + return ResolvedEntry{}, false +} + +func resolveDockerRegistry(vault *apm.Vault, name string) (ResolvedEntry, bool) { + for _, e := range vault.DockerRegistries { + if e.Name == name && matchSpace(vault, e.Space) { + return newResolved(name, "DockerRegistry", e.Token), true + } + } + return ResolvedEntry{}, false +} + +func resolveSSHConfig(vault *apm.Vault, name string) (ResolvedEntry, bool) { + for _, e := range vault.SSHConfigs { + if e.Alias == name && matchSpace(vault, e.Space) { + return newResolved(name, "SSHConfig", e.PrivateKey), true + } + } + return ResolvedEntry{}, false +} From ac94e9d3de5b728674ca295dcca26757938f4d32 Mon Sep 17 00:00:00 2001 From: Aarav Maloo Date: Mon, 16 Mar 2026 19:21:22 +0530 Subject: [PATCH 6/6] create main inject logic --- cmd/inject.go | 299 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 299 insertions(+) create mode 100644 cmd/inject.go diff --git a/cmd/inject.go b/cmd/inject.go new file mode 100644 index 0000000..f086083 --- /dev/null +++ b/cmd/inject.go @@ -0,0 +1,299 @@ +package cmd + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/fatih/color" + "github.com/spf13/cobra" + + "github.com/aaravmaloo/apm/internal/inject" + apm "github.com/aaravmaloo/apm/src" +) + +func BuildInjectCmd(unlock func() (string, *apm.Vault, bool, error)) *cobra.Command { + var injectFlag string + + injectCmd := &cobra.Command{ + Use: "inject", + Short: "Inject vault entries into the current shell session", + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + if unlock == nil { + return errors.New("unlock handler not configured") + } + if len(args) > 0 { + return fmt.Errorf("unexpected arguments: %s", strings.Join(args, " ")) + } + + if session, err := inject.ReadSession(); err == nil && session != nil { + return fmt.Errorf("inject session already active [%s]. Run 'pm inject kill' first", session.ID) + } else if err != nil && !errors.Is(err, inject.ErrSessionNotFound) { + return err + } + + _, vault, _, err := unlock() + if err != nil { + return err + } + + names, mappings, err := resolveInjectTargets(injectFlag) + if err != nil { + return err + } + + resolved, err := inject.ResolveEntries(vault, names) + if err != nil { + return err + } + + if len(mappings) > 0 { + for i := range resolved { + if mappings[i].As != "" { + resolved[i].EnvVarName = mappings[i].As + } + } + } + + for i := range resolved { + if resolved[i].EnvVarName == "" { + resolved[i].EnvVarName = inject.ToEnvVarName(resolved[i].EntryName) + } + } + + shellEnv := os.Getenv("SHELL") + if strings.TrimSpace(shellEnv) == "" { + if os.Getenv("PSModulePath") != "" { + shellEnv = "powershell" + } else if comspec := os.Getenv("ComSpec"); strings.TrimSpace(comspec) != "" { + shellEnv = comspec + } + } + if _, ok := inject.DetectShellFromEnv(shellEnv); !ok { + fmt.Fprintln(os.Stderr, "Warning: unknown shell; defaulting to bash syntax") + } + if strings.Contains(strings.ToLower(shellEnv), "powershell") || os.Getenv("PSModulePath") != "" { + fmt.Fprintln(os.Stderr, "PowerShell tip: run `pm inject | Invoke-Expression` or use `pm inject setup-shell`.") + } + + eval, err := inject.StartSession(resolved, shellEnv) + if err != nil { + return err + } + + session, _ := inject.ReadSession() + sessionID := "" + if session != nil { + sessionID = session.ID + } + + entryNames := make([]string, 0, len(resolved)) + envNames := make([]string, 0, len(resolved)) + for _, e := range resolved { + entryNames = append(entryNames, e.EntryName) + if e.EnvVarName != "" { + envNames = append(envNames, e.EnvVarName) + } + } + + apm.LogAction("INJECT_START", fmt.Sprintf("session=%s entries=%s shell=%s", sessionID, strings.Join(entryNames, ","), shellEnv)) + + fmt.Fprint(os.Stdout, eval) + + green := color.New(color.FgGreen) + if sessionID != "" { + green.Fprintf(os.Stderr, "✓ Injected %d vars into session [%s]\n", len(envNames), sessionID) + } else { + green.Fprintf(os.Stderr, "✓ Injected %d vars into session\n", len(envNames)) + } + if len(envNames) > 0 { + fmt.Fprintln(os.Stderr, " "+strings.Join(envNames, ", ")) + } + + return nil + }, + } + + injectCmd.Flags().StringVar(&injectFlag, "inject", "", "Comma-separated list of vault entries to inject") + + killCmd := &cobra.Command{ + Use: "kill", + Short: "Kill the active injection session and wipe env vars", + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) > 0 { + return fmt.Errorf("unexpected arguments: %s", strings.Join(args, " ")) + } + + shellEnv := os.Getenv("SHELL") + if strings.TrimSpace(shellEnv) == "" && os.Getenv("PSModulePath") != "" { + shellEnv = "powershell" + } + if _, ok := inject.DetectShellFromEnv(shellEnv); !ok { + fmt.Fprintln(os.Stderr, "Warning: unknown shell; defaulting to bash syntax") + } + if strings.Contains(strings.ToLower(shellEnv), "powershell") || os.Getenv("PSModulePath") != "" { + fmt.Fprintln(os.Stderr, "PowerShell tip: run `pm inject kill | Invoke-Expression` or use `pm inject setup-shell`.") + } + + session, err := inject.ReadSession() + if err != nil { + if errors.Is(err, inject.ErrSessionNotFound) { + fmt.Fprintln(os.Stderr, "No active injection session found.") + return nil + } + return err + } + + eval, err := inject.KillSession() + if err != nil { + return err + } + + fmt.Fprint(os.Stdout, eval) + + duration := time.Since(session.InjectedAt) + apm.LogAction("INJECT_KILL", fmt.Sprintf("session=%s duration=%s method=manual", session.ID, formatDuration(duration))) + + color.New(color.FgGreen).Fprintf(os.Stderr, "✓ Session [%s] killed. %d vars wiped. (active %s)\n", session.ID, len(session.VarNames), formatDuration(duration)) + return nil + }, + } + + setupShellCmd := &cobra.Command{ + Use: "setup-shell", + Short: "Install an inject() shell function for eval-free usage", + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) > 0 { + return fmt.Errorf("unexpected arguments: %s", strings.Join(args, " ")) + } + return setupShellFunction() + }, + } + + injectCmd.AddCommand(killCmd, setupShellCmd) + + return injectCmd +} + +func resolveInjectTargets(injectFlag string) ([]string, []inject.InjectMapping, error) { + if strings.TrimSpace(injectFlag) != "" { + parts := strings.Split(injectFlag, ",") + names := make([]string, 0, len(parts)) + for _, p := range parts { + name := strings.TrimSpace(p) + if name == "" { + continue + } + names = append(names, name) + } + if len(names) == 0 { + return nil, nil, errors.New("no entries provided to --inject") + } + return names, nil, nil + } + + cwd, err := os.Getwd() + if err != nil { + return nil, nil, err + } + + path, err := inject.FindAPMInjectFile(cwd) + if err != nil { + if errors.Is(err, inject.ErrAPMInjectNotFound) { + return nil, nil, errors.New("No .apminject file found. Use --inject to specify entries explicitly, or create a .apminject file.") + } + return nil, nil, err + } + + mappings, err := inject.ParseAPMInjectFile(path) + if err != nil { + return nil, nil, err + } + + names := make([]string, 0, len(mappings)) + for _, m := range mappings { + names = append(names, m.Entry) + } + + if len(names) == 0 { + return nil, nil, fmt.Errorf("no entries listed in %s", path) + } + + return names, mappings, nil +} + +func setupShellFunction() error { + shellEnv := os.Getenv("SHELL") + shell, known := inject.DetectShellFromEnv(shellEnv) + if !known { + fmt.Fprintln(os.Stderr, "Warning: unknown shell; defaulting to bash configuration") + shell = inject.Bash + } + + home, err := os.UserHomeDir() + if err != nil { + return err + } + + var rcPath string + var snippet string + + switch shell { + case inject.Zsh: + rcPath = filepath.Join(home, ".zshrc") + snippet = "\n# APM inject helper\ninject() { eval $(pm inject \"$@\"); }\n" + case inject.Fish: + rcPath = filepath.Join(home, ".config", "fish", "config.fish") + snippet = "\n# APM inject helper\nfunction inject\n eval (pm inject $argv)\nend\n" + case inject.PowerShell: + profile := os.Getenv("PROFILE") + if strings.TrimSpace(profile) == "" { + return errors.New("PowerShell profile not found; set $PROFILE or configure manually") + } + rcPath = profile + snippet = "\n# APM inject helper\nfunction inject { Invoke-Expression (pm inject $args) }\n" + case inject.Bash: + fallthrough + default: + rcPath = filepath.Join(home, ".bashrc") + snippet = "\n# APM inject helper\ninject() { eval $(pm inject \"$@\"); }\n" + } + + existing, _ := os.ReadFile(rcPath) + if strings.Contains(string(existing), "inject() { eval $(pm inject") || strings.Contains(string(existing), "function inject") { + color.Yellow("inject() already configured in %s", rcPath) + return nil + } + + if err := os.MkdirAll(filepath.Dir(rcPath), 0700); err != nil { + return err + } + + f, err := os.OpenFile(rcPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) + if err != nil { + return err + } + defer f.Close() + + if _, err := f.WriteString(snippet); err != nil { + return err + } + + color.Green("inject() function added to %s", rcPath) + return nil +} + +func formatDuration(d time.Duration) string { + if d < 0 { + d = 0 + } + if d > time.Second { + d = d.Round(time.Second) + } + return d.String() +}