From 0a781de0cd1d63fb4ba4b1de836cbecc9a3590f9 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 23 May 2026 12:20:09 +0200 Subject: [PATCH] =?UTF-8?q?add=20ClaudeConfig=20service=20=E2=80=94=20laye?= =?UTF-8?q?red=20Claude=20env=20+=20version-keyed=20slash=20probe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builtin-owned, app-wide source of truth for Claude Code's environment (D-76): skills, custom commands, settings, and permission rules read from ~/.claude and the repo's .claude, layered local-over-global, watched for changes. Built-in slash commands come from the stream-json `init` event, captured by a one-turn probe cached in clide's own dir keyed on the resolved claude version — so it runs at most once per claude version per machine. load() stays cheap (version + cache-read + disk + watch); the paid probe is a lazy ensureProbe() consumers call on first need, so app-init and tests never pay for a model turn. Wired into the Claude extension lifecycle and exposed as a builtin singleton. T-151. Co-Authored-By: Claude Opus 4.7 (1M context) --- .pql/changelog/ticket_history/2026-05.sql | 1 + .pql/changelog/tickets/2026-05.sql | 1 + lib/builtin/claude/src/claude_config.dart | 508 ++++++++++++++++++++ lib/builtin/claude/src/extension.dart | 28 ++ test/builtin/claude/claude_config_test.dart | 297 ++++++++++++ 5 files changed, 835 insertions(+) create mode 100644 lib/builtin/claude/src/claude_config.dart create mode 100644 test/builtin/claude/claude_config_test.dart diff --git a/.pql/changelog/ticket_history/2026-05.sql b/.pql/changelog/ticket_history/2026-05.sql index 738f4f8e..b70576f8 100644 --- a/.pql/changelog/ticket_history/2026-05.sql +++ b/.pql/changelog/ticket_history/2026-05.sql @@ -2201,3 +2201,4 @@ INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-145', 'status', 'in_progress', 'done', NULL, '2026-05-23 08:43:19', '2026-05-23 08:43:19', '2026-05-23 08:43:19', NULL, 'c9cf511becf7e542d465701e51dba4e2', 1) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-150', 'status', 'backlog', 'in_progress', NULL, '2026-05-23 09:04:54', '2026-05-23 09:04:54', '2026-05-23 09:04:54', NULL, 'fcc43ef1e1ae3c1dfa0e9c94ffa35bed', 1) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-150', 'status', 'in_progress', 'done', NULL, '2026-05-23 09:33:03', '2026-05-23 09:33:03', '2026-05-23 09:33:03', NULL, '58c9b0734a70fe8c7083c3cc456a1bd6', 1) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-151', 'status', 'backlog', 'in_progress', NULL, '2026-05-23 09:54:31', '2026-05-23 09:54:31', '2026-05-23 09:54:31', NULL, '52df08bff6479b6a84c128f5a63ea312', 1) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-05.sql b/.pql/changelog/tickets/2026-05.sql index 8b18197e..cdb2318e 100644 --- a/.pql/changelog/tickets/2026-05.sql +++ b/.pql/changelog/tickets/2026-05.sql @@ -2587,3 +2587,4 @@ INSERT INTO tickets (id, type, parent_id, title, description, status, priority, INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-152', 'task', 'T-132', 'Composer slash-command typeahead (inline detection, CLI-style)', 'Make ClaudeComposer (lib/builtin/claude/src/claude_composer.dart) slash-aware. Detect a /token at the cursor ANYWHERE in the text — inline mid-sentence, not only at position 0 (user requirement) — and show a typeahead popup listing matching slash commands + skills sourced from ClaudeConfig (T-151), filtering as the token grows. Keyboard nav: Up/Down to move, Tab/Enter to complete, Esc to dismiss; reuse ClidePalette interaction patterns. Completing inserts the command token at the cursor. Never re-scans the filesystem — reads from ClaudeConfig and refreshes when it changes. Acceptance: typing / opens the list; an inline / after existing text also opens it; arrow/Tab/Enter/Esc behave; the list reflects ClaudeConfig (skills + custom commands + built-ins); widget tests; a11y (focusable, labelled, contrast). Blocked by T-151.', 'backlog', 'medium', NULL, NULL, 'D-76', '2026-05-23 09:52:20', '2026-05-23 09:52:20', NULL, 'b83a83ba0e72ba1855afca1bc5f9f8cd', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-153', 'task', 'T-132', 'Command-aware send: bypass bracketed-paste for valid slash commands', 'Fix the delivery mismatch where slash commands behave differently in clide than the CLI. tmux.sendMessage (lib/builtin/claude/src/tmux_session.dart) always uses paste-buffer -p (bracketed paste), and Claude''s TUI deliberately does not run slash-command parsing on bracketed-pasted content — so /cmd and /skill arrive as literal prompt text instead of invoking. Fix: when the submitted input is a valid slash command (per ClaudeConfig, T-151) — or single-line input generally — deliver it typed via tmux send-keys -l -- then Enter, so the TUI parses it like the CLI; keep paste-buffer -p only for multi-line content (mirrors encodeClaudeInput''s existing single-vs-multiline logic on the PTY fallback path). Acceptance: a typed or typeahead-selected slash command actually invokes the skill/command in a real make-run session (parity with the CLI); multi-line messages still arrive as one block, not a stream of submits; unit tests on the send-encoding branches (single-line vs multi-line vs recognized-command); coverage >= floor. Blocked by T-151.', 'backlog', 'medium', NULL, NULL, 'D-76', '2026-05-23 09:52:27', '2026-05-23 09:52:27', NULL, '61392ef8e182bc4be406639b9898bcac', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-154', 'task', 'T-132', 'Surface ClaudeConfig in the Claude status pane', 'The status surface shows LIVE model / permission-mode / context-tokens from the active transcript (T-145, T-150). Complement it with the CONFIGURED side from ClaudeConfig (T-151): available-skills count and/or configured permission/model defaults — static environment state alongside live session state. Reads from ClaudeConfig, not the filesystem; updates when config changes. Acceptance: the status surface reflects ClaudeConfig values, refreshes on config change, and keeps the live transcript-driven fields working; widget test; coverage >= floor. Blocked by T-151.', 'backlog', 'low', NULL, NULL, 'D-76', '2026-05-23 09:52:32', '2026-05-23 09:52:32', NULL, '407255f39712397b5e6fdde42a0cce0c', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-151', 'task', 'T-132', 'ClaudeConfig service: layered config + skills/commands + version-keyed slash probe', 'Builtin-owned (lib/builtin/claude/) app-wide source of truth for Claude Code''s environment per D-76. Loads and layers GLOBAL (~/.claude) as base with LOCAL (.claude) overriding: skills (skills/*/SKILL.md frontmatter name+description), custom slash commands (commands/*.md), settings.json, and permission rules (allow/deny/ask). Built-in slash commands (not on disk) come from a one-shot ''claude --output-format stream-json'' probe, cached keyed on the resolved claude version id so additions/deprecations re-capture on upgrade; a small static list is the fallback. FileWatcher (lib/src/files/watcher.dart) on both .claude dirs plus an explicit refresh; expose typed, listenable views. No kernel changes — builtin-owned (Claude is a non-disableable extension, but still an extension). Acceptance: empirically confirm the stream-json init message carries slash_commands for the pinned CC version (spike folded in here) before consumers rely on it; unit tests with fixture global+local .claude dirs covering layering, watcher-driven refresh, probe cache keyed on version, and static fallback; degrades gracefully on parse miss; coverage >= floor. Blocks the typeahead, command-aware send, and status-pane wiring.', 'in_progress', 'high', NULL, NULL, 'D-76', '2026-05-23 09:52:14', '2026-05-23 09:54:31', NULL, '65cd97999dfa1b15ffbf59e88478d5e2', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); diff --git a/lib/builtin/claude/src/claude_config.dart b/lib/builtin/claude/src/claude_config.dart new file mode 100644 index 00000000..47dc2893 --- /dev/null +++ b/lib/builtin/claude/src/claude_config.dart @@ -0,0 +1,508 @@ +/// ClaudeConfig (T-151, D-76): builtin-owned, app-wide source of truth for +/// Claude Code's environment — skills, custom slash commands, settings, and +/// permission rules — read from the GLOBAL (`~/.claude`) and LOCAL (`.claude`) +/// scopes and layered local-over-global. +/// +/// Built-in slash commands aren't on disk; they come from a one-shot +/// stream-json `init` probe of the `claude` CLI. The probe costs one minimal +/// turn, so its result is cached in clide's OWN global dir keyed on the +/// resolved claude version — it runs at most once per claude version per +/// machine, shared across every clide instance (the data is claude-locked, +/// not workspace-locked). We read Claude's config but never write into +/// `~/.claude` (same boundary as pql's data, D-3). +/// +/// Consumers (composer typeahead, status pane) read from here; none re-scan +/// the filesystem or re-derive the command list. +library; + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:clide/src/files/ignore.dart'; +import 'package:clide/src/files/watcher.dart'; +import 'package:flutter/foundation.dart'; +import 'package:yaml/yaml.dart'; + +/// The live, app-wide instance once the Claude extension has activated +/// (builtin-owned singleton, D-76). Null before activation and in tests that +/// don't wire it. Consumers should accept an injected [ClaudeConfig] for +/// testability and fall back to this in production. +ClaudeConfig? activeClaudeConfig; + +enum ConfigScope { global, local } + +@immutable +class ClaudeSkill { + const ClaudeSkill({required this.name, this.description, required this.scope}); + final String name; + final String? description; + final ConfigScope scope; +} + +@immutable +class ClaudeCommand { + const ClaudeCommand({required this.name, required this.scope}); + final String name; + final ConfigScope scope; +} + +@immutable +class ClaudePermissions { + const ClaudePermissions({this.allow = const [], this.deny = const [], this.ask = const []}); + final List allow; + final List deny; + final List ask; + bool get isEmpty => allow.isEmpty && deny.isEmpty && ask.isEmpty; +} + +/// The slice of session metadata the stream-json `init` event carries that +/// isn't derivable from disk: the full slash-command list (built-ins + custom +/// + plugin + MCP), the skill names, the default model and permission mode. +@immutable +class ClaudeProbe { + const ClaudeProbe({ + required this.version, + required this.slashCommands, + required this.skills, + this.model, + this.permissionMode, + }); + + final String version; + final List slashCommands; + final List skills; + final String? model; + final String? permissionMode; + + Map toJson() => { + 'version': version, + 'slash_commands': slashCommands, + 'skills': skills, + if (model != null) 'model': model, + if (permissionMode != null) 'permission_mode': permissionMode, + }; + + /// Build from a stream-json `init` event object. Returns null if it doesn't + /// look like an init event (no version field). + static ClaudeProbe? fromInitEvent(Map j, {required String version}) { + if (j['slash_commands'] == null && j['claude_code_version'] == null) return null; + return ClaudeProbe( + version: version, + slashCommands: _stringList(j['slash_commands']), + skills: _stringList(j['skills']), + model: j['model'] as String?, + permissionMode: j['permissionMode'] as String?, + ); + } + + static ClaudeProbe fromCache(Map j) => ClaudeProbe( + version: (j['version'] as String?) ?? '', + slashCommands: _stringList(j['slash_commands']), + skills: _stringList(j['skills']), + model: j['model'] as String?, + permissionMode: j['permission_mode'] as String?, + ); +} + +List _stringList(Object? v) => v is List ? v.whereType().toList(growable: false) : const []; + +/// Resolves the installed claude version string (e.g. "2.1.150 (Claude +/// Code)"), or null if `claude` can't be run. +typedef ClaudeVersionRunner = Future Function(); + +/// Runs the one-shot init probe and returns its raw stream-json stdout, or +/// null on failure. +typedef ClaudeInitProbe = Future Function(); + +/// Returns a change stream for [dir] (fires on any file event under it). +typedef ClaudeConfigWatch = Stream Function(Directory dir); + +/// Modest version-agnostic fallback used when the probe is unavailable, so +/// the typeahead still offers the common built-ins. +const List kFallbackSlashCommands = [ + 'add-dir', + 'agents', + 'clear', + 'compact', + 'config', + 'context', + 'cost', + 'doctor', + 'exit', + 'help', + 'init', + 'mcp', + 'memory', + 'model', + 'permissions', + 'resume', + 'review', + 'status', + 'usage', +]; + +class ClaudeConfig extends ChangeNotifier { + ClaudeConfig({ + required Directory globalDir, + required Directory cacheDir, + Directory? projectDir, + ClaudeVersionRunner? versionRunner, + ClaudeInitProbe? initProbe, + ClaudeConfigWatch? watch, + Duration debounce = const Duration(milliseconds: 150), + }) : _globalDir = globalDir, + _cacheDir = cacheDir, + _projectDir = projectDir, + _versionRunner = versionRunner ?? _defaultVersionRunner, + _initProbe = initProbe ?? _defaultInitProbe, + _watch = watch, + _debounceFor = debounce; + + final Directory _globalDir; + final Directory _cacheDir; + Directory? _projectDir; + final ClaudeVersionRunner _versionRunner; + final ClaudeInitProbe _initProbe; + final ClaudeConfigWatch? _watch; + final Duration _debounceFor; + + String? _version; + ClaudeProbe? _probe; + bool _probing = false; + List _skills = const []; + List _commands = const []; + Map _settings = const {}; + ClaudePermissions _permissions = const ClaudePermissions(); + String? _error; + + final List _watchers = []; + final List> _subs = []; + Timer? _debounce; + + // ---- Public, listenable views ------------------------------------------- + + /// Resolved claude version (e.g. "2.1.150"), or null if claude is missing. + String? get version => _version; + + /// True once a claude version resolved — the healthcheck signal. + bool get ready => _version != null; + + /// Last error encountered resolving the environment, if any. + String? get error => _error; + + ClaudeProbe? get probe => _probe; + + /// All slash commands for the typeahead — the probe's authoritative list + /// (built-ins + custom + plugin + MCP), or the static fallback. + List get slashCommands => _probe?.slashCommands ?? kFallbackSlashCommands; + + List get skills => _skills; + List get commands => _commands; + Map get settings => Map.unmodifiable(_settings); + ClaudePermissions get permissions => _permissions; + + // ---- Lifecycle ---------------------------------------------------------- + + /// Full load — cheap and side-effect-light: resolve the version (`claude + /// --version`, no model turn), read the version-keyed probe cache if it + /// already exists, read the layered disk config, and start watching. Never + /// runs the paid probe — call [ensureProbe] for that. + Future load() async { + _error = null; + _version = _parseVersion(await _guard(_versionRunner)); + await _readProbeCache(); + await _loadDiskConfig(); + _startWatchers(); + notifyListeners(); + } + + /// Run the one-turn init probe if we don't already have its data (cache + /// miss / first use after a claude upgrade), then cache it. Idempotent and + /// safe to call repeatedly; consumers (the slash typeahead) call it lazily + /// on first need so app-init and tests never pay for a model turn. + Future ensureProbe() async { + if (_probe != null || _probing) return; + final v = _version; + if (v == null) return; + _probing = true; + try { + final probe = _parseInitProbe(await _guard(_initProbe), v); + if (probe == null) return; // stay on the static fallback + _probe = probe; + await _writeProbeCache(probe); + notifyListeners(); + } finally { + _probing = false; + } + } + + /// Re-read the on-disk config (skills/commands/settings/permissions). The + /// watcher calls this on change; callers can force it. Version + probe are + /// not re-resolved (the binary doesn't change under us at runtime). + Future refresh() async { + await _loadDiskConfig(); + notifyListeners(); + } + + /// Point the local scope at a different workspace (on project switch). Keeps + /// the same instance — and its listeners — re-reading disk and re-watching + /// for the new repo. The global scope and probe are unaffected. + Future setProjectDir(Directory? dir) async { + _stopWatching(); + _projectDir = dir; + await _loadDiskConfig(); + _startWatchers(); + notifyListeners(); + } + + @override + void dispose() { + _stopWatching(); + super.dispose(); + } + + void _stopWatching() { + _debounce?.cancel(); + _debounce = null; + for (final s in _subs) { + unawaited(s.cancel()); + } + _subs.clear(); + for (final w in _watchers) { + unawaited(w.stop()); + } + _watchers.clear(); + } + + // ---- Probe (version-keyed cache in clide's own dir) --------------------- + + File get _cacheFile => File('${_cacheDir.path}/init-$_version.json'); + + /// Read the version-keyed cache if present. Read-only; no shell-out. + Future _readProbeCache() async { + _probe = null; + if (_version == null) return; + final file = _cacheFile; + if (!await file.exists()) return; + try { + final j = jsonDecode(await file.readAsString()) as Map; + final cached = ClaudeProbe.fromCache(j); + if (cached.version == _version) _probe = cached; + } catch (_) { + // Corrupt cache — leave null; ensureProbe will re-probe on demand. + } + } + + Future _writeProbeCache(ClaudeProbe probe) async { + try { + await _cacheDir.create(recursive: true); + await _cacheFile.writeAsString(jsonEncode(probe.toJson())); + } catch (_) { + // A non-writable cache dir is non-fatal; we just re-probe next launch. + } + } + + ClaudeProbe? _parseInitProbe(String? raw, String version) { + if (raw == null) return null; + for (final line in const LineSplitter().convert(raw)) { + final trimmed = line.trim(); + if (trimmed.isEmpty || !trimmed.startsWith('{')) continue; + Map j; + try { + j = jsonDecode(trimmed) as Map; + } catch (_) { + continue; + } + if (j['type'] == 'system' && j['subtype'] == 'init') { + return ClaudeProbe.fromInitEvent(j, version: version); + } + } + return null; + } + + // ---- Disk config (layered global -> local) ------------------------------ + + Future _loadDiskConfig() async { + final skills = []; + final commands = []; + final settings = {}; + final allow = [], deny = [], ask = []; + + for (final (scope, dir) in _scopeDirs()) { + skills.addAll(await _loadSkills(dir, scope)); + commands.addAll(await _loadCommands(dir, scope)); + final s = await _loadSettings(dir); + settings.addAll(s); // local overrides global per top-level key + final p = _permissionsOf(s); + allow.addAll(p.allow); + deny.addAll(p.deny); + ask.addAll(p.ask); + } + + _skills = _dedupeByName(skills, (s) => s.name); + _commands = _dedupeByName(commands, (c) => c.name); + _settings = settings; + _permissions = ClaudePermissions(allow: _uniq(allow), deny: _uniq(deny), ask: _uniq(ask)); + } + + /// Global first so that local entries, added later, win on collisions. + List<(ConfigScope, Directory)> _scopeDirs() { + final pd = _projectDir; + return [ + (ConfigScope.global, _globalDir), + if (pd != null) (ConfigScope.local, Directory('${pd.path}/.claude')), + ]; + } + + Future> _loadSkills(Directory scopeDir, ConfigScope scope) async { + final dir = Directory('${scopeDir.path}/skills'); + if (!await dir.exists()) return const []; + final out = []; + await for (final entry in dir.list()) { + if (entry is! Directory) continue; + final manifest = File('${entry.path}/SKILL.md'); + if (!await manifest.exists()) continue; + final fm = _parseFrontmatter(await manifest.readAsString()); + out.add(ClaudeSkill( + name: fm.name ?? _basename(entry.path), + description: fm.description, + scope: scope, + )); + } + return out; + } + + Future> _loadCommands(Directory scopeDir, ConfigScope scope) async { + final dir = Directory('${scopeDir.path}/commands'); + if (!await dir.exists()) return const []; + final out = []; + await for (final entry in dir.list()) { + if (entry is! File || !entry.path.endsWith('.md')) continue; + final base = _basename(entry.path); + out.add(ClaudeCommand(name: base.substring(0, base.length - 3), scope: scope)); + } + return out; + } + + Future> _loadSettings(Directory scopeDir) async { + final file = File('${scopeDir.path}/settings.json'); + if (!await file.exists()) return const {}; + try { + final j = jsonDecode(await file.readAsString()); + return j is Map ? j.map((k, v) => MapEntry('$k', v)) : const {}; + } catch (_) { + return const {}; // a malformed settings file shouldn't sink the load + } + } + + ClaudePermissions _permissionsOf(Map settings) { + final p = settings['permissions']; + if (p is! Map) return const ClaudePermissions(); + return ClaudePermissions( + allow: _stringList(p['allow']), + deny: _stringList(p['deny']), + ask: _stringList(p['ask']), + ); + } + + // ---- Watching ----------------------------------------------------------- + + void _startWatchers() { + final source = _watch ?? _defaultWatch; + for (final (_, dir) in _scopeDirs()) { + if (!dir.existsSync()) continue; + _subs.add(source(dir).listen((_) => _onChange())); + } + } + + Stream _defaultWatch(Directory dir) { + final w = FileWatcher(root: dir, ignore: IgnoreSet.parse(const [])); + _watchers.add(w); + unawaited(w.start()); + return w.stream.map((_) {}); + } + + void _onChange() { + _debounce?.cancel(); + _debounce = Timer(_debounceFor, () => unawaited(refresh())); + } + + // ---- Helpers ------------------------------------------------------------ + + Future _guard(Future Function() f) async { + try { + return await f(); + } catch (e) { + _error = '$e'; + return null; + } + } + + static String? _parseVersion(String? raw) { + if (raw == null) return null; + final m = RegExp(r'(\d+\.\d+\.\d+)').firstMatch(raw); + return m?.group(1); + } + + static String _basename(String path) => path.split(Platform.pathSeparator).last; + + ({String? name, String? description}) _parseFrontmatter(String content) { + final body = content.replaceFirst('\r\n', '\n'); + if (!body.startsWith('---')) return (name: null, description: null); + final end = body.indexOf('\n---', 3); + if (end < 0) return (name: null, description: null); + try { + final y = loadYaml(body.substring(3, end)); + if (y is Map) { + return (name: y['name'] as String?, description: y['description'] as String?); + } + } catch (_) { + // Unparseable frontmatter — caller falls back to the dir name. + } + return (name: null, description: null); + } + + static List _dedupeByName(List all, String Function(T) nameOf) { + final byName = {}; + for (final item in all) { + byName[nameOf(item)] = item; // later (local) scope wins + } + final out = byName.values.toList(); + out.sort((a, b) => nameOf(a).compareTo(nameOf(b))); + return out; + } + + static List _uniq(List xs) { + final seen = {}; + return [ + for (final x in xs) + if (seen.add(x)) x, + ]; + } +} + +Future _defaultVersionRunner() async { + try { + final r = await Process.run('claude', ['--version']); + return r.stdout as String?; + } catch (_) { + return null; + } +} + +Future _defaultInitProbe() async { + try { + final r = await Process.run('claude', [ + '-p', + '.', + '--no-session-persistence', + '--output-format', + 'stream-json', + '--verbose', + ]); + return r.stdout as String?; + } catch (_) { + return null; + } +} diff --git a/lib/builtin/claude/src/extension.dart b/lib/builtin/claude/src/extension.dart index cc69b5ed..a6fcb95b 100644 --- a/lib/builtin/claude/src/extension.dart +++ b/lib/builtin/claude/src/extension.dart @@ -1,6 +1,8 @@ import 'dart:async'; +import 'dart:io'; import 'package:clide/clide.dart'; +import 'package:clide/builtin/claude/src/claude_config.dart'; import 'package:clide/builtin/claude/src/claude_session_host.dart'; import 'package:clide/builtin/claude/src/session_naming.dart'; import 'package:clide/builtin/claude/src/pane_context_status.dart'; @@ -25,8 +27,13 @@ class ClaudeExtension extends ClideExtension { final GlobalKey _hostKey = GlobalKey(); TeamObserver? _observer; + ClaudeConfig? _config; final List> _subs = []; + /// App-wide Claude environment (skills, commands, settings, permissions, + /// slash list). Built and loaded at activation (D-76, T-151). + ClaudeConfig? get config => _config; + @override List get contributions => [ TabContribution( @@ -65,6 +72,24 @@ class ClaudeExtension extends ClideExtension { @override Future activate(ClideExtensionContext ctx) async { _ctx = ctx; + + // Resolve the Claude environment up front (app-init): version + the + // version-keyed slash probe + the layered global/local config. Exposed + // as the builtin-owned singleton so panes + the status item read one + // source of truth (D-76, T-151). Reloaded as the workspace changes. + final home = Platform.environment['HOME']; + if (home != null) { + final cfg = ClaudeConfig( + globalDir: Directory('$home/.claude'), + cacheDir: Directory('${ctx.settings.appDir.path}/claude'), + projectDir: ctx.settings.projectDir, + ); + _config = cfg; + activeClaudeConfig = cfg; + unawaited(cfg.load()); + _subs.add(ctx.events.on().listen((e) => cfg.setProjectDir(Directory(e.path)))); + } + // Cold-start reap: kill any leftover secondary tmux sessions from // a previous run. D-41's "secondary numbering resets between // clide runs" only holds if the leftovers are gone before the new @@ -106,6 +131,9 @@ class ClaudeExtension extends ClideExtension { } _subs.clear(); _stopObserver(); + if (identical(activeClaudeConfig, _config)) activeClaudeConfig = null; + _config?.dispose(); + _config = null; // Best-effort cleanup on explicit extension teardown. The cold- // start reap in activate is the actual safety net. final primary = await _primarySessionName(); diff --git a/test/builtin/claude/claude_config_test.dart b/test/builtin/claude/claude_config_test.dart new file mode 100644 index 00000000..12dc1e0c --- /dev/null +++ b/test/builtin/claude/claude_config_test.dart @@ -0,0 +1,297 @@ +/// Tests for ClaudeConfig (T-151, D-76): layered global+local config load, +/// version-keyed init-probe cache, static fallback, watcher-driven refresh, +/// and graceful degradation on parse misses / a missing claude. +library; + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:clide/builtin/claude/src/claude_config.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + late Directory tmp; + late Directory globalDir; // stands in for ~/.claude + late Directory projectDir; // repo root; local config under .claude + late Directory localDir; // /.claude + late Directory cacheDir; // clide's own global dir for the version cache + + setUp(() async { + tmp = await Directory.systemTemp.createTemp('claude_config_test'); + globalDir = Directory('${tmp.path}/global')..createSync(); + projectDir = Directory('${tmp.path}/project')..createSync(); + localDir = Directory('${projectDir.path}/.claude')..createSync(); + cacheDir = Directory('${tmp.path}/clide-cache')..createSync(); + }); + + tearDown(() async { + if (await tmp.exists()) await tmp.delete(recursive: true); + }); + + Future writeSkill(Directory scope, String dirName, {String? name, String? description}) async { + final d = Directory('${scope.path}/skills/$dirName')..createSync(recursive: true); + final fm = StringBuffer('---\n'); + if (name != null) fm.writeln('name: $name'); + if (description != null) fm.writeln('description: $description'); + fm + ..writeln('---') + ..writeln('skill body'); + await File('${d.path}/SKILL.md').writeAsString(fm.toString()); + } + + Future writeCommand(Directory scope, String fileName) async { + final d = Directory('${scope.path}/commands')..createSync(recursive: true); + await File('${d.path}/$fileName').writeAsString('# command'); + } + + Future writeSettings(Directory scope, Map json) async { + await File('${scope.path}/settings.json').writeAsString(jsonEncode(json)); + } + + String initLine({ + String version = '2.1.150', + List slash = const ['clear', 'pql'], + List skills = const ['pql'], + }) => + '${jsonEncode({ + 'type': 'system', + 'subtype': 'init', + 'claude_code_version': version, + 'slash_commands': slash, + 'skills': skills, + 'model': 'claude-opus-4-7', + 'permissionMode': 'default', + })}\n'; + + ClaudeConfig build({ + ClaudeVersionRunner? versionRunner, + ClaudeInitProbe? initProbe, + ClaudeConfigWatch? watch, + Duration debounce = Duration.zero, + }) => + ClaudeConfig( + globalDir: globalDir, + cacheDir: cacheDir, + projectDir: projectDir, + versionRunner: versionRunner ?? () async => '2.1.150 (Claude Code)\n', + initProbe: initProbe ?? () async => initLine(), + // Default: never start a real FileWatcher in tests. + watch: watch ?? (_) => const Stream.empty(), + debounce: debounce, + ); + + test('parses the version out of the --version banner', () async { + final c = build(); + await c.load(); + expect(c.version, '2.1.150'); + expect(c.ready, isTrue); + c.dispose(); + }); + + test('a missing claude leaves version null, not ready, and falls back', () async { + final c = build(versionRunner: () async => null); + await c.load(); + expect(c.version, isNull); + expect(c.ready, isFalse); + expect(c.probe, isNull); + expect(c.slashCommands, kFallbackSlashCommands); + c.dispose(); + }); + + test('layers skills/commands/settings/permissions local-over-global', () async { + await writeSkill(globalDir, 'shared', name: 'shared', description: 'from-global'); + await writeSkill(globalDir, 'only-global', name: 'only-global'); + await writeSkill(localDir, 'shared', name: 'shared', description: 'from-local'); + await writeSkill(localDir, 'only-local', name: 'only-local'); + await writeCommand(globalDir, 'gcmd.md'); + await writeCommand(localDir, 'lcmd.md'); + await writeSettings(globalDir, { + 'model': 'opus', + 'keep': 1, + 'permissions': { + 'allow': ['Bash'], + 'deny': ['Write'] + }, + }); + await writeSettings(localDir, { + 'model': 'sonnet', + 'permissions': { + 'allow': ['Edit'], + 'ask': ['Read'] + }, + }); + + final c = build(); + await c.load(); + + expect(c.skills.map((s) => s.name), ['only-global', 'only-local', 'shared']); + final shared = c.skills.firstWhere((s) => s.name == 'shared'); + expect(shared.scope, ConfigScope.local, reason: 'local wins on a name collision'); + expect(shared.description, 'from-local'); + + expect(c.commands.map((x) => x.name), ['gcmd', 'lcmd']); + + expect(c.settings['model'], 'sonnet'); // local overrides + expect(c.settings['keep'], 1); // global-only key survives + expect(c.permissions.allow, ['Bash', 'Edit']); // union across scopes + expect(c.permissions.deny, ['Write']); + expect(c.permissions.ask, ['Read']); + c.dispose(); + }); + + test('a skill with no frontmatter falls back to its directory name', () async { + final d = Directory('${globalDir.path}/skills/bare')..createSync(recursive: true); + await File('${d.path}/SKILL.md').writeAsString('no frontmatter here'); + final c = build(); + await c.load(); + final bare = c.skills.firstWhere((s) => s.name == 'bare'); + expect(bare.description, isNull); + c.dispose(); + }); + + test('load stays on the fallback until ensureProbe runs (no eager turn)', () async { + var probeCalls = 0; + final c = build(initProbe: () async { + probeCalls++; + return initLine(slash: ['clear', 'pql', 'whats-next']); + }); + await c.load(); + expect(probeCalls, 0, reason: 'load must never pay for a model turn'); + expect(c.slashCommands, kFallbackSlashCommands); + + await c.ensureProbe(); + expect(probeCalls, 1); + expect(c.slashCommands, contains('whats-next')); + c.dispose(); + }); + + test('ensureProbe writes the version-keyed cache, which load then reuses', () async { + var probeCalls = 0; + Future probe() async { + probeCalls++; + return initLine(slash: ['clear', 'pql', 'whats-next']); + } + + final c1 = build(initProbe: probe); + await c1.load(); + await c1.ensureProbe(); + expect(probeCalls, 1); + expect(File('${cacheDir.path}/init-2.1.150.json').existsSync(), isTrue); + c1.dispose(); + + // A fresh instance, same version → load reads the cache, no probe needed. + final c2 = build(initProbe: probe); + await c2.load(); + expect(c2.probe, isNotNull, reason: 'cache hit at load time'); + expect(c2.slashCommands, contains('whats-next')); + await c2.ensureProbe(); + expect(probeCalls, 1, reason: 'already have probe data → no re-probe'); + c2.dispose(); + }); + + test('a different claude version misses the cache and re-probes', () async { + var probeCalls = 0; + final c1 = build(initProbe: () async { + probeCalls++; + return initLine(); + }); + await c1.load(); + await c1.ensureProbe(); + expect(probeCalls, 1); + c1.dispose(); + + final c2 = build( + versionRunner: () async => '2.2.0 (Claude Code)\n', + initProbe: () async { + probeCalls++; + return initLine(version: '2.2.0', slash: ['clear', 'new-cmd']); + }, + ); + await c2.load(); + expect(c2.probe, isNull, reason: 'no cache for 2.2.0 yet'); + await c2.ensureProbe(); + expect(probeCalls, 2); + expect(c2.slashCommands, contains('new-cmd')); + expect(File('${cacheDir.path}/init-2.2.0.json').existsSync(), isTrue); + c2.dispose(); + }); + + test('a failed/garbage probe falls back to the static list and does not cache', () async { + final c = build(initProbe: () async => 'not json at all\n{"type":"system"}\n'); + await c.load(); + await c.ensureProbe(); + expect(c.probe, isNull); + expect(c.slashCommands, kFallbackSlashCommands); + expect(File('${cacheDir.path}/init-2.1.150.json').existsSync(), isFalse); + c.dispose(); + }); + + test('skips non-init json lines when parsing the probe stream', () async { + final stream = StringBuffer() + ..write('{"type":"system","subtype":"hook_started"}\n') + ..write(initLine(slash: ['clear', 'compact'])) + ..write('{"type":"assistant"}\n'); + final c = build(initProbe: () async => stream.toString()); + await c.load(); + await c.ensureProbe(); + expect(c.slashCommands, ['clear', 'compact']); + c.dispose(); + }); + + test('malformed settings.json does not sink the load', () async { + await File('${globalDir.path}/settings.json').writeAsString('{ this is not json'); + await writeSkill(globalDir, 'ok', name: 'ok'); + final c = build(); + await c.load(); + expect(c.settings, isEmpty); + expect(c.skills.map((s) => s.name), ['ok']); + c.dispose(); + }); + + test('a watcher event refreshes the on-disk view', () async { + final ctrl = StreamController.broadcast(); + addTearDown(ctrl.close); + final c = build(watch: (_) => ctrl.stream); + await c.load(); + expect(c.skills, isEmpty); + + await writeSkill(globalDir, 'late', name: 'late'); + ctrl.add(null); + await Future.delayed(const Duration(milliseconds: 30)); + + expect(c.skills.map((s) => s.name), ['late']); + c.dispose(); + }); + + test('setProjectDir swaps the local scope and keeps the global one', () async { + await writeSkill(globalDir, 'g1', name: 'g1'); + await writeSkill(localDir, 'p1', name: 'p1'); + final c = build(); + await c.load(); + expect(c.skills.map((s) => s.name), ['g1', 'p1']); + + final proj2 = Directory('${tmp.path}/project2')..createSync(); + await writeSkill(Directory('${proj2.path}/.claude'), 'p2', name: 'p2'); + await c.setProjectDir(proj2); + + expect(c.skills.map((s) => s.name), ['g1', 'p2'], reason: 'local scope follows the workspace'); + c.dispose(); + }); + + test('explicit refresh re-reads disk without re-resolving the version', () async { + var versionCalls = 0; + final c = build(versionRunner: () async { + versionCalls++; + return '2.1.150 (Claude Code)\n'; + }); + await c.load(); + expect(versionCalls, 1); + + await writeCommand(globalDir, 'fresh.md'); + await c.refresh(); + expect(c.commands.map((x) => x.name), ['fresh']); + expect(versionCalls, 1, reason: 'refresh is disk-only'); + c.dispose(); + }); +}