Skip to content

Commit 0d606e3

Browse files
author
Developer
committed
Cross-worktree tracking, settings.local.json support
- SessionTracker resolves main worktree root via git common dir so all worktrees share one .live-agents file - ralph:init prompts for settings.json vs settings.local.json, adds --local/--shared flags for non-interactive use - checkSandboxConfig merges both settings files (local takes precedence)
1 parent b080caa commit 0d606e3

4 files changed

Lines changed: 114 additions & 22 deletions

File tree

src/Commands/InitCommand.php

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,20 @@
55
use Illuminate\Console\Command;
66
use Illuminate\Support\Facades\File;
77

8+
use function Laravel\Prompts\select;
9+
810
class InitCommand extends Command
911
{
10-
protected $signature = 'ralph:init';
12+
protected $signature = 'ralph:init
13+
{--local : Write to settings.local.json}
14+
{--shared : Write to settings.json}';
1115

12-
protected $description = 'Initialize .claude/settings.json with Ralph sandbox config';
16+
protected $description = 'Initialize Claude settings with Ralph sandbox config';
1317

1418
public function handle(): int
1519
{
16-
$settingsPath = base_path('.claude/settings.json');
20+
$settingsFile = $this->resolveTargetFile();
21+
$settingsPath = base_path('.claude/'.$settingsFile);
1722

1823
/** @var array<string, mixed> $requiredSettings */
1924
$requiredSettings = [
@@ -29,20 +34,40 @@ public function handle(): int
2934
$existing = json_decode(File::get($settingsPath), true);
3035

3136
if (! is_array($existing)) {
32-
$this->components->error('Existing .claude/settings.json is not valid JSON.');
37+
$this->components->error("Existing .claude/{$settingsFile} is not valid JSON.");
3338

3439
return self::FAILURE;
3540
}
3641

3742
$merged = array_replace_recursive($existing, $requiredSettings);
3843
File::put($settingsPath, json_encode($merged, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)."\n");
39-
$this->components->info('Updated .claude/settings.json with Ralph sandbox config.');
44+
$this->components->info("Updated .claude/{$settingsFile} with Ralph sandbox config.");
4045
} else {
4146
File::ensureDirectoryExists(dirname($settingsPath));
4247
File::put($settingsPath, json_encode($requiredSettings, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)."\n");
43-
$this->components->info('Created .claude/settings.json with Ralph sandbox config.');
48+
$this->components->info("Created .claude/{$settingsFile} with Ralph sandbox config.");
4449
}
4550

4651
return self::SUCCESS;
4752
}
53+
54+
private function resolveTargetFile(): string
55+
{
56+
if ($this->option('local')) {
57+
return 'settings.local.json';
58+
}
59+
60+
if ($this->option('shared')) {
61+
return 'settings.json';
62+
}
63+
64+
/** @var string */
65+
return select(
66+
label: 'Which settings file should Ralph configure?',
67+
options: [
68+
'settings.json' => 'settings.json — shared, committed to repo',
69+
'settings.local.json' => 'settings.local.json — personal, gitignored',
70+
],
71+
);
72+
}
4873
}

src/Commands/StartCommand.php

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -527,21 +527,14 @@ private function buildEnvExportString(): string
527527

528528
private function checkSandboxConfig(): void
529529
{
530-
$settingsPath = base_path('.claude/settings.json');
530+
$settings = $this->loadMergedClaudeSettings();
531531

532-
if (! File::exists($settingsPath)) {
532+
if ($settings === []) {
533533
$this->components->warn('No .claude/settings.json found. Run `php artisan ralph:init` to configure sandbox permissions.');
534534

535535
return;
536536
}
537537

538-
/** @var array<string, mixed>|null $settings */
539-
$settings = json_decode(File::get($settingsPath), true);
540-
541-
if (! is_array($settings)) {
542-
return;
543-
}
544-
545538
/** @var array<string, mixed> $sandbox */
546539
$sandbox = $settings['sandbox'] ?? [];
547540
$sandboxEnabled = $sandbox['enabled'] ?? false;
@@ -552,6 +545,34 @@ private function checkSandboxConfig(): void
552545
}
553546
}
554547

548+
/**
549+
* Load .claude/settings.json merged with .claude/settings.local.json (local takes precedence).
550+
*
551+
* @return array<string, mixed>
552+
*/
553+
private function loadMergedClaudeSettings(): array
554+
{
555+
$settings = [];
556+
557+
foreach (['settings.json', 'settings.local.json'] as $file) {
558+
$path = base_path('.claude/'.$file);
559+
560+
if (! File::exists($path)) {
561+
continue;
562+
}
563+
564+
/** @var array<string, mixed>|null $decoded */
565+
$decoded = json_decode(File::get($path), true);
566+
567+
if (is_array($decoded)) {
568+
/** @var array<string, mixed> $settings */
569+
$settings = array_replace_recursive($settings, $decoded);
570+
}
571+
}
572+
573+
return $settings;
574+
}
575+
555576
private function createSessionLogger(string $name): RalphLogger
556577
{
557578
/** @var string $logDir */

src/RalphServiceProvider.php

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
namespace Woda\Ralph;
44

55
use Illuminate\Contracts\Foundation\Application;
6+
use Illuminate\Support\Facades\Process;
67
use Illuminate\Support\ServiceProvider;
78
use Woda\Ralph\Commands\AttachCommand;
89
use Woda\Ralph\Commands\InitCommand;
@@ -31,12 +32,34 @@ public function register(): void
3132
$trackingFile = config('ralph.tracking.file');
3233

3334
return new SessionTracker(
34-
trackingFile: base_path($trackingFile),
35+
trackingFile: $this->resolveMainWorktreeRoot().'/'.$trackingFile,
3536
screenManager: $app->make(ScreenManager::class),
3637
);
3738
});
3839
}
3940

41+
/**
42+
* Resolve the main worktree root so all worktrees share one tracking file.
43+
*/
44+
private function resolveMainWorktreeRoot(): string
45+
{
46+
try {
47+
$result = Process::path(base_path())->run('git rev-parse --path-format=absolute --git-common-dir');
48+
49+
if ($result->successful()) {
50+
$gitCommonDir = trim($result->output());
51+
52+
if ($gitCommonDir !== '' && str_starts_with($gitCommonDir, '/')) {
53+
return dirname($gitCommonDir);
54+
}
55+
}
56+
} catch (\Throwable) {
57+
// Not in a git repo or git not available
58+
}
59+
60+
return base_path();
61+
}
62+
4063
public function boot(): void
4164
{
4265
$this->publishes([

tests/Feature/RalphCommandsTest.php

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,15 +31,15 @@
3131
->assertExitCode(0);
3232
});
3333

34-
test('ralph:init creates settings file when none exists', function () {
34+
test('ralph:init creates settings.json with --shared flag', function () {
3535
$claudeDir = base_path('.claude');
3636
$settingsPath = $claudeDir.'/settings.json';
3737

3838
// Ensure clean state
3939
File::deleteDirectory($claudeDir);
4040

41-
$this->artisan('ralph:init')
42-
->expectsOutputToContain('Created')
41+
$this->artisan('ralph:init --shared')
42+
->expectsOutputToContain('Created .claude/settings.json')
4343
->assertExitCode(0);
4444

4545
expect(File::exists($settingsPath))->toBeTrue();
@@ -55,6 +55,29 @@
5555
File::deleteDirectory($claudeDir);
5656
});
5757

58+
test('ralph:init creates settings.local.json with --local flag', function () {
59+
$claudeDir = base_path('.claude');
60+
$localPath = $claudeDir.'/settings.local.json';
61+
62+
// Ensure clean state
63+
File::deleteDirectory($claudeDir);
64+
65+
$this->artisan('ralph:init --local')
66+
->expectsOutputToContain('Created .claude/settings.local.json')
67+
->assertExitCode(0);
68+
69+
expect(File::exists($localPath))->toBeTrue();
70+
71+
/** @var array<string, mixed> $settings */
72+
$settings = json_decode(File::get($localPath), true);
73+
74+
expect($settings['sandbox']['enabled'])->toBeTrue()
75+
->and($settings['sandbox']['autoAllowBashIfSandboxed'])->toBeTrue();
76+
77+
// Cleanup
78+
File::deleteDirectory($claudeDir);
79+
});
80+
5881
test('ralph:init merges with existing settings', function () {
5982
$claudeDir = base_path('.claude');
6083
$settingsPath = $claudeDir.'/settings.json';
@@ -65,8 +88,8 @@
6588
'permissions' => ['existingPerm' => true],
6689
], JSON_PRETTY_PRINT));
6790

68-
$this->artisan('ralph:init')
69-
->expectsOutputToContain('Updated')
91+
$this->artisan('ralph:init --shared')
92+
->expectsOutputToContain('Updated .claude/settings.json')
7093
->assertExitCode(0);
7194

7295
/** @var array<string, mixed> $settings */
@@ -89,7 +112,7 @@
89112
File::ensureDirectoryExists($claudeDir);
90113
File::put($settingsPath, 'not valid json{{{');
91114

92-
$this->artisan('ralph:init')
115+
$this->artisan('ralph:init --shared')
93116
->expectsOutputToContain('not valid JSON')
94117
->assertExitCode(1);
95118

0 commit comments

Comments
 (0)