Implements D-106. The T-439 login-shell probe is a global heuristic with a known hole — login-but-non-interactive shells skip ~/.bashrc, so interactive-only additions (brew shellenv) never reach the agent's Bash tool or terminal panes on a desktop launch. The preset is the explicit per-repo layer on top: user-scope storage keyed by repo identity (a linked worktree resolves through its gitdir pointer to the main repo, so worktrees share the preset), prepended at spawn via the PaneRegistry pathForSpawn hook and agentEnvDelta prependDirs — which now exports PATH even when clide is already resolvable, closing the gap where the hosted session inherited the sparse GUI PATH untouched. CLI half: `clide env path list|set|add|remove|clear|capture` over an injected Flutter-free store port; capture diffs the login-shell PATH against the process PATH to suggest the dirs a desktop launch dropped. Binary resolution (toolchain, supporter pins, bundled pql/git) stays preset-blind per the D-92/T-98 fence. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
186 lines
8.0 KiB
Dart
186 lines
8.0 KiB
Dart
/// Tests for the clide-hosted agent bootstrap (Epic B / T-215..T-217, D-83).
|
|
library;
|
|
|
|
import 'package:clide/builtin/claude/src/agent_bootstrap.dart';
|
|
import 'package:clide/src/ipc/paths.dart' show workspaceSocketPath;
|
|
import 'package:test/test.dart';
|
|
|
|
void main() {
|
|
group('clideContextNote (T-216)', () {
|
|
test('names clide, the clide CLI, the env vars, and the parity contract', () {
|
|
final note = clideContextNote('/home/dev/proj');
|
|
expect(note, contains('inside clide'));
|
|
expect(note, contains('clide <subsystem> <verb>'));
|
|
expect(note, contains('CLIDE_WORKSPACE'));
|
|
expect(note, contains('CLIDE_SOCK'));
|
|
expect(note, contains('/home/dev/proj'));
|
|
expect(note, contains('D-6'));
|
|
});
|
|
|
|
test('lists only subsystems that dispatch today; not the unfinished status snapshot', () {
|
|
final note = clideContextNote('/repo');
|
|
expect(note, contains('git status'));
|
|
expect(note, contains('editor active'));
|
|
// `clide status` (T-221) and live pane reflection (Epic C) are not wired
|
|
// yet — the note must not point the agent at a command that returns
|
|
// nothing, mirroring the T-213 "never mislead an agent" guard.
|
|
expect(note, isNot(contains('clide status')));
|
|
});
|
|
});
|
|
|
|
group('clideSkillsNote (T-490)', () {
|
|
test('names both skills, nudges first-turn use, and stays short', () {
|
|
final note = clideSkillsNote();
|
|
expect(note, contains('`pql`'));
|
|
expect(note, contains('`clide`'));
|
|
expect(note, contains('from your first turn'));
|
|
// Acceptance #4: a short nudge, not a wall of text crowding clideContextNote.
|
|
expect(note.length, lessThan(360));
|
|
});
|
|
});
|
|
|
|
group('clideBashAllowRule (T-217)', () {
|
|
test('is the command-scoped Bash rule and rides on --allowedTools', () {
|
|
expect(clideBashAllowRule, 'Bash(clide:*)');
|
|
expect(clideAllowedToolsArgs, ['--allowedTools', 'Bash(clide:*)']);
|
|
});
|
|
});
|
|
|
|
group('agentEnvDelta (T-215)', () {
|
|
test('always exports CLIDE_SOCK + CLIDE_WORKSPACE', () {
|
|
final d = agentEnvDelta(workspaceRoot: '/repo', socketPath: '/run/clide/abc.sock', currentPath: '/usr/bin', clideCliDir: null);
|
|
expect(d['CLIDE_WORKSPACE'], '/repo');
|
|
expect(d['CLIDE_SOCK'], '/run/clide/abc.sock');
|
|
});
|
|
|
|
test('leaves PATH untouched when clide is already resolvable (clideCliDir null)', () {
|
|
final d = agentEnvDelta(workspaceRoot: '/repo', socketPath: '/s.sock', currentPath: '/usr/bin', clideCliDir: null);
|
|
expect(d.containsKey('PATH'), isFalse);
|
|
});
|
|
|
|
test('prepends the cli dir to PATH when given', () {
|
|
final d = agentEnvDelta(workspaceRoot: '/repo', socketPath: '/s.sock', currentPath: '/usr/bin:/bin', clideCliDir: '/home/dev/.local/bin');
|
|
expect(d['PATH'], '/home/dev/.local/bin:/usr/bin:/bin');
|
|
});
|
|
|
|
test('sets PATH to just the cli dir when there is no current PATH', () {
|
|
final d = agentEnvDelta(workspaceRoot: '/repo', socketPath: '/s.sock', currentPath: null, clideCliDir: '/opt/clide/bin');
|
|
expect(d['PATH'], '/opt/clide/bin');
|
|
});
|
|
|
|
test('exports PATH for a preset even when clide is already resolvable (D-106)', () {
|
|
final d = agentEnvDelta(workspaceRoot: '/repo', socketPath: '/s.sock', currentPath: '/usr/bin:/bin', clideCliDir: null, prependDirs: ['/opt/go/bin']);
|
|
expect(d['PATH'], '/opt/go/bin:/usr/bin:/bin');
|
|
});
|
|
|
|
test('preset dirs come first, then the cli dir, then the current PATH (D-106)', () {
|
|
final d = agentEnvDelta(
|
|
workspaceRoot: '/repo',
|
|
socketPath: '/s.sock',
|
|
currentPath: '/usr/bin',
|
|
clideCliDir: '/home/dev/.local/bin',
|
|
prependDirs: ['/opt/go/bin', '/brew/bin'],
|
|
);
|
|
expect(d['PATH'], '/opt/go/bin:/brew/bin:/home/dev/.local/bin:/usr/bin');
|
|
});
|
|
|
|
test('a preset dir already on the current PATH is not duplicated (D-106)', () {
|
|
final d = agentEnvDelta(
|
|
workspaceRoot: '/repo',
|
|
socketPath: '/s.sock',
|
|
currentPath: '/opt/go/bin:/usr/bin',
|
|
clideCliDir: null,
|
|
prependDirs: ['/opt/go/bin'],
|
|
);
|
|
expect(d['PATH'], '/opt/go/bin:/usr/bin');
|
|
});
|
|
});
|
|
|
|
group('resolveClideCliDir (T-215)', () {
|
|
test('returns null when clide already resolves on PATH (no PATH change needed)', () {
|
|
final dir = resolveClideCliDir(
|
|
currentPath: '/usr/bin:/home/dev/.local/bin',
|
|
candidateDirs: const ['/repo/native/linux-x64'],
|
|
isExecutableFile: (p) => p == '/home/dev/.local/bin/clide',
|
|
);
|
|
expect(dir, isNull);
|
|
});
|
|
|
|
test('returns the first candidate holding an executable clide when not on PATH', () {
|
|
final dir = resolveClideCliDir(
|
|
currentPath: '/usr/bin',
|
|
candidateDirs: const ['/nope', '/repo/native/linux-x64', '/also'],
|
|
isExecutableFile: (p) => p == '/repo/native/linux-x64/clide',
|
|
);
|
|
expect(dir, '/repo/native/linux-x64');
|
|
});
|
|
|
|
test('returns null when nothing holds clide', () {
|
|
final dir = resolveClideCliDir(currentPath: '/usr/bin', candidateDirs: const ['/a', '/b'], isExecutableFile: (_) => false);
|
|
expect(dir, isNull);
|
|
});
|
|
});
|
|
|
|
group('claudeConfigDirForWorkspace (T-484)', () {
|
|
test('bound workspace → the account config dir', () {
|
|
expect(claudeConfigDirForWorkspace(cwd: '/repo/a', boundConfigDir: (_) => '/home/u/.claude-work', env: const {}), '/home/u/.claude-work');
|
|
});
|
|
|
|
test('unbound + parent CLAUDE_CONFIG_DIR set → respects the launcher choice', () {
|
|
expect(
|
|
claudeConfigDirForWorkspace(cwd: '/repo/a', boundConfigDir: (_) => null, env: const {'CLAUDE_CONFIG_DIR': '/home/u/.claude-personal'}),
|
|
'/home/u/.claude-personal',
|
|
);
|
|
});
|
|
|
|
test('unbound + no parent env → null (Claude defaults to ~/.claude)', () {
|
|
expect(claudeConfigDirForWorkspace(cwd: '/repo/a', boundConfigDir: (_) => null, env: const {}), isNull);
|
|
});
|
|
|
|
test('binding beats the parent env (binding > parent)', () {
|
|
expect(claudeConfigDirForWorkspace(cwd: '/repo/a', boundConfigDir: (_) => '/bound', env: const {'CLAUDE_CONFIG_DIR': '/parent'}), '/bound');
|
|
});
|
|
|
|
test('an empty bound dir is ignored (falls through to parent / null)', () {
|
|
expect(claudeConfigDirForWorkspace(cwd: '/r', boundConfigDir: (_) => '', env: const {'CLAUDE_CONFIG_DIR': '/parent'}), '/parent');
|
|
expect(claudeConfigDirForWorkspace(cwd: '/r', boundConfigDir: (_) => '', env: const {}), isNull);
|
|
});
|
|
});
|
|
|
|
group('agentBootstrap (IO wrapper)', () {
|
|
test('socket in the delta matches workspaceSocketPath; allow rule in extraArgs', () {
|
|
final b = agentBootstrap('/some/workspace');
|
|
expect(b.envDelta['CLIDE_SOCK'], workspaceSocketPath('/some/workspace'));
|
|
expect(b.envDelta['CLIDE_WORKSPACE'], '/some/workspace');
|
|
expect(b.extraArgs, ['--allowedTools', 'Bash(clide:*)']);
|
|
});
|
|
|
|
test('merges over the provided base env', () {
|
|
final b = agentBootstrap('/ws', base: {'FOO': 'bar'});
|
|
expect(b.envDelta['FOO'], 'bar');
|
|
expect(b.envDelta['CLIDE_WORKSPACE'], '/ws');
|
|
});
|
|
|
|
test('injects the bound workspace CLAUDE_CONFIG_DIR (T-484)', () {
|
|
final b = agentBootstrap('/ws', boundConfigDir: (_) => '/home/u/.claude-work');
|
|
expect(b.envDelta['CLAUDE_CONFIG_DIR'], '/home/u/.claude-work');
|
|
});
|
|
|
|
test('an explicit base CLAUDE_CONFIG_DIR override beats the workspace binding (T-484)', () {
|
|
final b = agentBootstrap('/ws', base: {'CLAUDE_CONFIG_DIR': '/override'}, boundConfigDir: (_) => '/bound');
|
|
expect(b.envDelta['CLAUDE_CONFIG_DIR'], '/override');
|
|
});
|
|
|
|
test('the workspace PATH preset lands at the head of the delta PATH (D-106)', () {
|
|
final b = agentBootstrap('/ws', pathPreset: (cwd) => cwd == '/ws' ? ['/opt/go/bin'] : const []);
|
|
expect(b.envDelta['PATH'], startsWith('/opt/go/bin:'));
|
|
});
|
|
|
|
test('no preset wired → bootstrap behaves as before (no gratuitous PATH export)', () {
|
|
final emptyPreset = agentBootstrap('/ws', pathPreset: (_) => const []);
|
|
final unwired = agentBootstrap('/ws');
|
|
expect(emptyPreset.envDelta.containsKey('PATH'), unwired.envDelta.containsKey('PATH'));
|
|
});
|
|
});
|
|
}
|