add ClaudeConfig service — layered Claude env + version-keyed slash probe

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-23 12:20:09 +02:00
co-authored by Claude Opus 4.7
parent e27b2b7fb1
commit 0a781de0cd
5 changed files with 835 additions and 0 deletions
@@ -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;
+1
View File
@@ -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 -- <text> 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);
+508
View File
@@ -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<String> allow;
final List<String> deny;
final List<String> 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<String> slashCommands;
final List<String> skills;
final String? model;
final String? permissionMode;
Map<String, Object?> 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<String, Object?> 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<String, Object?> 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<String> _stringList(Object? v) => v is List ? v.whereType<String>().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<String?> Function();
/// Runs the one-shot init probe and returns its raw stream-json stdout, or
/// null on failure.
typedef ClaudeInitProbe = Future<String?> Function();
/// Returns a change stream for [dir] (fires on any file event under it).
typedef ClaudeConfigWatch = Stream<void> Function(Directory dir);
/// Modest version-agnostic fallback used when the probe is unavailable, so
/// the typeahead still offers the common built-ins.
const List<String> 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<ClaudeSkill> _skills = const [];
List<ClaudeCommand> _commands = const [];
Map<String, Object?> _settings = const {};
ClaudePermissions _permissions = const ClaudePermissions();
String? _error;
final List<FileWatcher> _watchers = [];
final List<StreamSubscription<void>> _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<String> get slashCommands => _probe?.slashCommands ?? kFallbackSlashCommands;
List<ClaudeSkill> get skills => _skills;
List<ClaudeCommand> get commands => _commands;
Map<String, Object?> 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<void> 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<void> 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<void> 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<void> 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<void> _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<String, Object?>;
final cached = ClaudeProbe.fromCache(j);
if (cached.version == _version) _probe = cached;
} catch (_) {
// Corrupt cache — leave null; ensureProbe will re-probe on demand.
}
}
Future<void> _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<String, Object?> j;
try {
j = jsonDecode(trimmed) as Map<String, Object?>;
} catch (_) {
continue;
}
if (j['type'] == 'system' && j['subtype'] == 'init') {
return ClaudeProbe.fromInitEvent(j, version: version);
}
}
return null;
}
// ---- Disk config (layered global -> local) ------------------------------
Future<void> _loadDiskConfig() async {
final skills = <ClaudeSkill>[];
final commands = <ClaudeCommand>[];
final settings = <String, Object?>{};
final allow = <String>[], deny = <String>[], ask = <String>[];
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<List<ClaudeSkill>> _loadSkills(Directory scopeDir, ConfigScope scope) async {
final dir = Directory('${scopeDir.path}/skills');
if (!await dir.exists()) return const [];
final out = <ClaudeSkill>[];
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<List<ClaudeCommand>> _loadCommands(Directory scopeDir, ConfigScope scope) async {
final dir = Directory('${scopeDir.path}/commands');
if (!await dir.exists()) return const [];
final out = <ClaudeCommand>[];
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<Map<String, Object?>> _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<String, Object?> 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<void> _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<String?> _guard(Future<String?> 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<T> _dedupeByName<T>(List<T> all, String Function(T) nameOf) {
final byName = <String, T>{};
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<String> _uniq(List<String> xs) {
final seen = <String>{};
return [
for (final x in xs)
if (seen.add(x)) x,
];
}
}
Future<String?> _defaultVersionRunner() async {
try {
final r = await Process.run('claude', ['--version']);
return r.stdout as String?;
} catch (_) {
return null;
}
}
Future<String?> _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;
}
}
+28
View File
@@ -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<ClaudeSessionHostState> _hostKey = GlobalKey();
TeamObserver? _observer;
ClaudeConfig? _config;
final List<StreamSubscription<dynamic>> _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<ContributionPoint> get contributions => [
TabContribution(
@@ -65,6 +72,24 @@ class ClaudeExtension extends ClideExtension {
@override
Future<void> 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<ProjectOpened>().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();
+297
View File
@@ -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; // <projectDir>/.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<void> 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<void> writeCommand(Directory scope, String fileName) async {
final d = Directory('${scope.path}/commands')..createSync(recursive: true);
await File('${d.path}/$fileName').writeAsString('# command');
}
Future<void> writeSettings(Directory scope, Map<String, Object?> json) async {
await File('${scope.path}/settings.json').writeAsString(jsonEncode(json));
}
String initLine({
String version = '2.1.150',
List<String> slash = const ['clear', 'pql'],
List<String> 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<void>.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<String?> 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<void>.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<void>.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();
});
}