feat(tools): Tools settings category + re-detect for supporter binaries (T-495)

ToolsSettingsExtension adds a Tools settings category — a path field per
tool (app.tools.<name>) plus a Re-detect action (tools.detect) — and keeps
the live resolver in sync as paths are edited (supporterBinariesFrom). en
+ nl catalogs. Completes the D-104 UI surface.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-29 09:28:46 +02:00
co-authored by Claude Opus 4.8
parent bd4a8332af
commit eac9612a5b
9 changed files with 207 additions and 0 deletions
+4
View File
@@ -18,6 +18,10 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
### Added ### Added
- **Tool path resolution + settings.** clide resolves supporter binaries (claude,
d2) via an explicit per-tool path, else PATH and the common install dirs — now
including Homebrew-on-Linux. A Tools settings category edits the paths and
re-detects; a broken path shows in Problems. (T-495, D-104)
- **Check for updates (About box).** Help → About has a manual "Check for - **Check for updates (About box).** Help → About has a manual "Check for
updates" button that compares your version to the latest GitHub release and updates" button that compares your version to the latest GitHub release and
links to the notes — explicit and user-initiated, no background polling (the links to the notes — explicit and user-initiated, no background polling (the
@@ -0,0 +1,11 @@
{
"command.detect": { "translation": "Re-detect tool paths" },
"settings.title": { "translation": "Tools" },
"settings.section.binaries": { "translation": "Supporter binaries" },
"settings.field.claude.label": { "translation": "Claude CLI" },
"settings.field.claude.help": { "translation": "Absolute path to the claude binary; blank to auto-resolve." },
"settings.field.d2.label": { "translation": "d2" },
"settings.field.d2.help": { "translation": "Absolute path to the d2 diagram compiler; blank to auto-resolve." },
"settings.field.detect.label": { "translation": "Re-detect" },
"settings.field.detect.help": { "translation": "Re-scan PATH and the common install dirs, overwriting the paths above." }
}
@@ -0,0 +1,11 @@
{
"command.detect": { "translation": "Tool-paden opnieuw detecteren" },
"settings.title": { "translation": "Tools" },
"settings.section.binaries": { "translation": "Hulpprogramma's" },
"settings.field.claude.label": { "translation": "Claude-CLI" },
"settings.field.claude.help": { "translation": "Absoluut pad naar het claude-binary; leeg om automatisch te bepalen." },
"settings.field.d2.label": { "translation": "d2" },
"settings.field.d2.help": { "translation": "Absoluut pad naar de d2-diagramcompiler; leeg om automatisch te bepalen." },
"settings.field.detect.label": { "translation": "Opnieuw detecteren" },
"settings.field.detect.help": { "translation": "Scan PATH en de gangbare installatiemappen opnieuw; overschrijft de paden hierboven." }
}
@@ -0,0 +1,112 @@
import 'package:clide/clide.dart';
import 'package:clide/extension/extension.dart';
import 'package:clide/kernel/kernel.dart';
import 'package:clide/src/env/supporter_binaries.dart';
/// Settings surface for the external supporter binaries clide shells out to —
/// claude, d2, … (D-104 / T-495). A path field per tool (`app.tools.<name>`),
/// auto-detected on first run, plus a Re-detect action. Editing a path rebuilds
/// the live resolver so the change takes effect without a restart.
class ToolsSettingsExtension extends ClideExtension {
@override
String get id => 'builtin.tools-settings';
@override
String get title => 'Tool paths';
@override
String get version => '0.1.0';
ClideExtensionContext? _ctx;
@override
Future<void> activate(ClideExtensionContext ctx) async {
_ctx = ctx;
ctx.settings.addListener(_sync);
}
@override
Future<void> deactivate() async {
_ctx?.settings.removeListener(_sync);
}
/// Rebuild the process-wide resolver from the current override keys so a path
/// edited in the settings panel applies live. Cheap — reads a couple of keys.
void _sync() {
final ctx = _ctx;
if (ctx == null) return;
activeSupporterBinaries = supporterBinariesFrom((k) => ctx.settings.get<Object>(k));
}
@override
List<ContributionPoint> get contributions => [
CommandContribution(
id: 'tools.detect',
command: 'tools.detect',
title: 'Re-detect tool paths',
titleKey: 'command.detect',
i18nNamespace: id,
run: _redetect,
),
SettingsCategoryContribution(
id: 'tools',
category: SettingsCategory(
id: 'tools',
title: 'Tools',
titleKey: 'settings.title',
i18nNamespace: id,
iconName: 'wrench',
priority: 60,
sections: [
SettingsSection(
label: 'Supporter binaries',
labelKey: 'settings.section.binaries',
fields: [
SettingsField(
key: supporterToolKey('claude'),
kind: SettingsFieldKind.text,
label: 'Claude CLI',
labelKey: 'settings.field.claude.label',
help: 'Absolute path to the claude binary; blank to auto-resolve.',
helpKey: 'settings.field.claude.help',
),
SettingsField(
key: supporterToolKey('d2'),
kind: SettingsFieldKind.text,
label: 'd2',
labelKey: 'settings.field.d2.label',
help: 'Absolute path to the d2 diagram compiler; blank to auto-resolve.',
helpKey: 'settings.field.d2.help',
),
SettingsField(
key: 'app.tools.redetect',
kind: SettingsFieldKind.file,
label: 'Re-detect',
labelKey: 'settings.field.detect.label',
help: 'Re-scan PATH and the common install dirs, overwriting the paths above.',
helpKey: 'settings.field.detect.help',
fileCommand: 'tools.detect',
),
],
),
],
),
),
];
Future<IpcResponse> _redetect(List<String> args) async {
final ctx = _ctx;
if (ctx == null) {
return IpcResponse.err(
id: '',
error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'tools-settings not activated'),
);
}
final resolver = await redetectSupporterBinaries(write: (k, v) => ctx.settings.set<Object?>(k, v));
activeSupporterBinaries = resolver;
return IpcResponse.ok(
id: '',
data: {
'detected': {for (final t in knownSupporterTools) t: resolver.resolve(t)},
},
);
}
}
@@ -0,0 +1 @@
export 'src/extension.dart';
+2
View File
@@ -27,6 +27,7 @@ import 'package:clide/builtin/problems/problems.dart';
import 'package:clide/builtin/settings_ui/settings_ui.dart'; import 'package:clide/builtin/settings_ui/settings_ui.dart';
import 'package:clide/builtin/terminal/terminal.dart'; import 'package:clide/builtin/terminal/terminal.dart';
import 'package:clide/builtin/theme_picker/theme_picker.dart'; import 'package:clide/builtin/theme_picker/theme_picker.dart';
import 'package:clide/builtin/tools_settings/tools_settings.dart';
import 'package:clide/builtin/view/view.dart'; import 'package:clide/builtin/view/view.dart';
import 'package:clide/builtin/vim/vim.dart'; import 'package:clide/builtin/vim/vim.dart';
import 'package:clide/builtin/tickets/tickets.dart'; import 'package:clide/builtin/tickets/tickets.dart';
@@ -565,6 +566,7 @@ Future<void> main() async {
..register(ExtensionsUiExtension()) ..register(ExtensionsUiExtension())
..register(KeybindingsUiExtension()) ..register(KeybindingsUiExtension())
..register(ClaudeControlExtension()) ..register(ClaudeControlExtension())
..register(ToolsSettingsExtension())
..register(CliInstallExtension()); ..register(CliInstallExtension());
await services.extensions.activateAll(); await services.extensions.activateAll();
+6
View File
@@ -149,6 +149,12 @@ Future<SupporterBinaries> redetectSupporterBinaries({
return SupporterBinaries(overrides: fresh); return SupporterBinaries(overrides: fresh);
} }
/// A resolver built from the current per-tool override keys, with no detect or
/// marker side effects — for rebuilding [activeSupporterBinaries] live when a
/// path is edited in settings.
SupporterBinaries supporterBinariesFrom(Object? Function(String key) read, {List<String> tools = knownSupporterTools}) =>
SupporterBinaries(overrides: _readOverrides(read, tools));
Map<String, String> _readOverrides(Object? Function(String) read, List<String> tools) { Map<String, String> _readOverrides(Object? Function(String) read, List<String> tools) {
final overrides = <String, String>{}; final overrides = <String, String>{};
for (final t in tools) { for (final t in tools) {
@@ -0,0 +1,47 @@
/// T-495: ToolsSettingsExtension contributes the Tools settings category and
/// the tools.detect command, and keeps the live resolver in sync with edits.
library;
import 'package:clide/builtin/tools_settings/tools_settings.dart';
import 'package:clide/extension/extension.dart';
import 'package:clide/src/env/supporter_binaries.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../helpers/kernel_fixture.dart';
void main() {
late KernelFixture f;
SupporterBinaries? saved;
setUp(() async {
saved = activeSupporterBinaries;
f = await KernelFixture.create();
f.services.extensions.register(ToolsSettingsExtension());
await f.services.extensions.activate('builtin.tools-settings');
});
tearDown(() {
activeSupporterBinaries = saved; // un-pollute the process-wide resolver
f.dispose();
});
test('contributes the Tools settings category and the tools.detect command', () {
final ext = ToolsSettingsExtension();
final cats = ext.contributions.whereType<SettingsCategoryContribution>();
expect(cats.any((c) => c.category.id == 'tools'), isTrue);
expect(f.services.commands.get('tools.detect'), isNotNull);
});
test('editing a tool path in settings rebuilds the live resolver', () async {
await f.services.settings.set<String>(supporterToolKey('d2'), '/custom/d2');
// The listener rebuilt activeSupporterBinaries from the keys; /custom/d2
// isn't a real file, so it reads as a stale pin — proving the edit took.
expect(activeSupporterBinaries?.isStalePin('d2'), isTrue);
});
test('tools.detect runs, marks first-run done, and refreshes the resolver', () async {
final r = await f.services.commands.execute('tools.detect');
expect(r.ok, isTrue);
expect(f.services.settings.get<bool>('app.tools.detected'), isTrue);
expect(activeSupporterBinaries, isNotNull);
});
}
+13
View File
@@ -105,4 +105,17 @@ void main() {
expect(store['app.tools.detected'], isTrue); expect(store['app.tools.detected'], isTrue);
}); });
}); });
group('supporterBinariesFrom', () {
test('builds a resolver from the override keys', () {
final store = <String, Object?>{'app.tools.d2': '/x/d2'};
final r = supporterBinariesFrom((k) => store[k], tools: ['d2']);
expect(r.isStalePin('d2'), isTrue); // /x/d2 not a real file → loaded as override
});
test('ignores blank or missing keys', () {
final store = <String, Object?>{'app.tools.d2': ''};
expect(supporterBinariesFrom((k) => store[k], tools: ['claude', 'd2']).isStalePin('d2'), isFalse);
});
});
} }