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:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user