Skip to content
Open
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
4 changes: 2 additions & 2 deletions cmd/kontext/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ func setupCmd() *cobra.Command {
Long: `Connect this Mac to your Kontext organization (self-serve managed observe).

Setup asks for the install token created in the Kontext dashboard, stores it
in your login keychain, installs the Claude Code hooks, and starts a
background agent that streams Claude Code activity to your workspace.
in your login keychain, installs hooks for supported local agents, and starts
a background agent that streams agent activity to your workspace.

Re-running setup is safe: it rotates the stored token and restarts the agent.
Use --uninstall to remove everything setup installed (the kontext binary
Expand Down
4 changes: 4 additions & 0 deletions cmd/kontext/setup_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package main

import (
"strings"
"testing"

"github.com/kontext-security/kontext-cli/internal/setup"
Expand Down Expand Up @@ -41,6 +42,9 @@ func TestSetupCmdRegistered(t *testing.T) {
if cmd.Use != "setup" {
t.Fatalf("Use = %q", cmd.Use)
}
if !strings.Contains(cmd.Long, "hooks for supported local agents") {
t.Fatalf("setup long help is not agent-oriented:\n%s", cmd.Long)
}
}

func TestSetupCmdSilencesUsageForRuntimeErrors(t *testing.T) {
Expand Down
15 changes: 15 additions & 0 deletions internal/agenthooks/config.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package agenthooks

import (
"encoding/json"
"errors"
"maps"
)
Expand All @@ -22,6 +23,20 @@ type Config struct {
HooksDescription string
}

// ToJSONAny round-trips a typed value through JSON so it lands in a generic
// map with the same shape WriteJSONFile will serialize.
func ToJSONAny(value any) (any, error) {
data, err := json.Marshal(value)
if err != nil {
return nil, err
}
var out any
if err := json.Unmarshal(data, &out); err != nil {
return nil, err
}
return out, nil
}

// HooksMap returns the hooks object from the config. A missing hooks key is an
// empty map; a non-object hooks value is left untouched and reported as an
// error because the file belongs to the user.
Expand Down
124 changes: 124 additions & 0 deletions internal/agenthooks/jsonfile.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package agenthooks

import (
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"time"
)

// ReadJSONFile parses a hook settings file into a generic map so unknown keys
// survive a read-merge-write round trip. A missing file is an empty map.
func ReadJSONFile(path, description string) (map[string]any, error) {
settings := map[string]any{}
raw, err := os.ReadFile(path)
if errors.Is(err, fs.ErrNotExist) {
return settings, nil
}
if err != nil {
return nil, err
}
if err := json.Unmarshal(raw, &settings); err != nil {
return nil, fmt.Errorf("parse %s: %w", description, err)
}
return settings, nil
}

// WriteJSONFile writes a hook settings map atomically, preserving existing
// permission bits. If path is a symlink, the symlink is left in place and its
// resolved target is rewritten.
func WriteJSONFile(path string, settings map[string]any) error {
writePath := path
if info, err := os.Lstat(path); err == nil && info.Mode()&os.ModeSymlink != 0 {
target, err := filepath.EvalSymlinks(path)
if err != nil {
return err
}
writePath = target
} else if err != nil && !errors.Is(err, fs.ErrNotExist) {
return err
}

mode := fs.FileMode(0o600)
if info, err := os.Stat(writePath); err == nil {
mode = info.Mode().Perm()
} else if !errors.Is(err, fs.ErrNotExist) {
return err
}
data, err := json.MarshalIndent(settings, "", " ")
if err != nil {
return err
}

temp, err := os.CreateTemp(filepath.Dir(writePath), ".settings-*.tmp")
if err != nil {
return err
}
tempPath := temp.Name()
defer os.Remove(tempPath)
if err := temp.Chmod(mode); err != nil {
temp.Close()
return err
}
if _, err := temp.Write(append(data, '\n')); err != nil {
temp.Close()
return err
}
if err := temp.Sync(); err != nil {
temp.Close()
return err
}
if err := temp.Close(); err != nil {
return err
}
return os.Rename(tempPath, writePath)
}

// BackupFile copies path aside with a timestamped suffix and matching
// permissions. Missing files are a no-op.
func BackupFile(path, label string) error {
info, err := os.Stat(path)
if errors.Is(err, fs.ErrNotExist) {
return nil
}
if err != nil {
return err
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
backupPathPrefix := fmt.Sprintf("%s.%s-backup-%s", path, label, time.Now().UTC().Format("20060102T150405.000000000Z"))
var file *os.File
for attempt := 0; attempt < 100; attempt++ {
backupPath := backupPathPrefix
if attempt > 0 {
backupPath = fmt.Sprintf("%s-%d", backupPathPrefix, attempt)
}
file, err = os.OpenFile(backupPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, info.Mode().Perm())
if errors.Is(err, fs.ErrExist) {
continue
}
if err != nil {
return err
}
break
}
if file == nil {
return fmt.Errorf("create backup for %s: too many timestamp collisions", path)
}
if _, err := file.Write(data); err != nil {
_ = file.Close()
return err
}
return file.Close()
}

// ShellQuote quotes one shell token for hook command strings.
func ShellQuote(value string) string {
return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'"
}
136 changes: 136 additions & 0 deletions internal/agenthooks/jsonfile_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
package agenthooks

import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)

func TestReadWriteJSONFilePreservesPermissions(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "hooks.json")

if err := WriteJSONFile(path, map[string]any{"a": float64(1)}); err != nil {
t.Fatal(err)
}
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != 0o600 {
t.Fatalf("new file mode = %v, want 0600", info.Mode().Perm())
}

if err := os.Chmod(path, 0o644); err != nil {
t.Fatal(err)
}
if err := WriteJSONFile(path, map[string]any{"a": float64(2)}); err != nil {
t.Fatal(err)
}
info, err = os.Stat(path)
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != 0o644 {
t.Fatalf("rewritten file mode = %v, want 0644 preserved", info.Mode().Perm())
}

got, err := ReadJSONFile(path, "test hooks")
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(got, map[string]any{"a": float64(2)}) {
t.Fatalf("round trip = %v", got)
}
}

func TestWriteJSONFilePreservesSymlink(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "target-hooks.json")
firstLink := filepath.Join(dir, "first-hooks.json")
link := filepath.Join(dir, "hooks.json")
if err := os.WriteFile(target, []byte(`{"a":1}`), 0o640); err != nil {
t.Fatal(err)
}
if err := os.Symlink(target, firstLink); err != nil {
t.Fatal(err)
}
if err := os.Symlink(firstLink, link); err != nil {
t.Fatal(err)
}

if err := WriteJSONFile(link, map[string]any{"a": float64(2)}); err != nil {
t.Fatal(err)
}
info, err := os.Lstat(link)
if err != nil {
t.Fatal(err)
}
if info.Mode()&os.ModeSymlink == 0 {
t.Fatalf("hooks path is no longer a symlink: mode=%v", info.Mode())
}
firstInfo, err := os.Lstat(firstLink)
if err != nil {
t.Fatal(err)
}
if firstInfo.Mode()&os.ModeSymlink == 0 {
t.Fatalf("intermediate hooks path is no longer a symlink: mode=%v", firstInfo.Mode())
}
data, err := os.ReadFile(target)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), `"a": 2`) {
t.Fatalf("target content = %s, want rewritten target", data)
}
}

func TestBackupFilePreservesModeAndContent(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "hooks.json")
if err := os.WriteFile(path, []byte(`{"a":1}`), 0o640); err != nil {
t.Fatal(err)
}
if err := BackupFile(path, "kontext-setup"); err != nil {
t.Fatal(err)
}
if err := BackupFile(path, "kontext-setup"); err != nil {
t.Fatal(err)
}

entries, err := os.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
backups := 0
for _, entry := range entries {
if !strings.Contains(entry.Name(), "kontext-setup-backup-") {
continue
}
backups++
backupPath := filepath.Join(dir, entry.Name())
info, err := os.Stat(backupPath)
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != 0o640 {
t.Fatalf("backup mode = %v, want original 0640", info.Mode().Perm())
}
data, err := os.ReadFile(backupPath)
if err != nil {
t.Fatal(err)
}
if string(data) != `{"a":1}` {
t.Fatalf("backup content = %s", data)
}
}
if backups != 2 {
t.Fatalf("backup count = %d, want 2", backups)
}

if err := BackupFile(filepath.Join(dir, "absent.json"), "x"); err != nil {
t.Fatal(err)
}
}
6 changes: 1 addition & 5 deletions internal/claudemanaged/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -314,11 +314,7 @@ func validateAsync(event Event, async *bool) error {
}

func hookCommand(kontextBinary, alias string) string {
return shellQuote(kontextBinary) + " hook " + shellQuote(alias)
}

func shellQuote(value string) string {
return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'"
return agenthooks.ShellQuote(kontextBinary) + " hook " + agenthooks.ShellQuote(alias)
}

func isAllMatcher(value string) bool {
Expand Down
Loading
Loading