distinguish a dev-tree clide from a packaged install (T-256)

CliInstaller.inspect() gains a devTree state: when `clide` on PATH resolves
to a dev-tree build artifact (native/<plat>/clide, the Makefile's
CLIDE_CLI_BIN output) it's classified separately from a packaged install
rather than silently treated as "installed". needsInstall stays false for a
dev build (it's intentional on a checkout, not a reinstall prompt), and the
launch-time check surfaces it as an info note. install() flags fromDevTree +
notes it in the result message when the copied source is a dev build.

Closes the last open child of the T-208 "give Claude hands" initiative.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-06 21:53:01 +02:00
co-authored by Claude Opus 4.8
parent 9cb7da3f57
commit 51cfa15c78
7 changed files with 116 additions and 4 deletions
@@ -495,3 +495,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-222', 'status', 'backlog', 'done', NULL, '2026-06-06 19:24:44', '2026-06-06 19:24:44', '2026-06-06 19:24:44', NULL, '7ba18fee0a7abf0f43ee86b5f8601a2e', 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-223', 'status', 'in_progress', 'done', NULL, '2026-06-06 19:24:44', '2026-06-06 19:24:44', '2026-06-06 19:24:44', NULL, 'c4edb71439a13551d1ec27b2e8bcf2d3', 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-225', 'status', 'in_progress', 'done', NULL, '2026-06-06 19:37:47', '2026-06-06 19:37:47', '2026-06-06 19:37:47', NULL, '5622a2bf33c28bc00aab480172366698', 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-256', 'status', 'backlog', 'in_progress', NULL, '2026-06-06 19:45:03', '2026-06-06 19:45:03', '2026-06-06 19:45:03', NULL, 'f9997e23005cbb1966f5e132d20524a5', 1) ON CONFLICT(hash) DO NOTHING;
+1
View File
@@ -1051,3 +1051,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-225', 'task', 'T-208', 'MCP transport reachable by an external agent', 'Gap 6 from self-analysis.md. The MCP SSE server (T-130, D-68) is live but mcp__ide__* tools are not exposed to an external agent out of the box, and getDiagnostics/executeCode were noted as stubs. The CLI path is the priority (Epics A-C); MCP follows. Decide + wire the minimal reachable MCP surface, or explicitly defer with a note. Relates to open question Q-32 (minimum MCP tool surface).
Refinement (2026-06-06): tool-surface question resolved. Q-32 closed by D-86 expose the full mcp__clide__* namespace, but GENERATE tools/list from the co-registered command registry (D-74) that already feeds the CLI + palette, so there is no hand-maintained second surface; add a per-command MCP opt-out for poor-fit verbs (long-lived streams, UI-side-effecting). Transport stays SSE-only per D-73 (Q-33 re-confirmed, not reopened). Remaining stubs to make real: getDiagnostics + executeCode. Scope is now: registry->MCP tool-definition adapter (arg-schema -> JSON-Schema), served over the existing SSE transport.', 'done', 'low', NULL, NULL, 'D-68', '2026-06-02 18:13:52', '2026-06-06 19:37:47', NULL, '45f318499292f9987fe8c8d99d9948e8', 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-256', 'task', 'T-208', 'Install affordance: distinguish a dev-tree clide from a production-installed build', 'Flagged during T-212 (2026-06-06). The Install-clide-command affordance copies whatever CliInstaller resolves as the bundled C client into ~/.local/bin, and ''make run'' points CLIDE_CLI_BIN at the dev-tree client (native/<plat>/clide). On a normal user machine that is correct: the bundled client beside the GUI runner IS the production build. But on a clide DEV system the clide on PATH should point at the properly-installed production build, not a dev-tree binary or stale build artefact. CliInstaller.inspect only classifies missing / staleGui (points into the Flutter bundle) / installed, and treats any non-GUI binary as installed without distinguishing dev-tree vs a real install. Good enough for now per the user; the affordance works for end users and fixes the stale-GUI-symlink footgun. Decide desired dev-machine behaviour (e.g. detect dev tree via CLIDE_PROJECT / repo-relative path and warn instead of silently installing, or prefer an already-correct production build) and refine inspect() accordingly.', 'in_progress', 'low', NULL, NULL, NULL, '2026-06-06 10:07:35', '2026-06-06 19:45:03', NULL, 'edd0f58a489218a6de945f8c0e1f92b3', 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);
+4
View File
@@ -124,6 +124,10 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
### Changed
- The `clide` CLI launch check now distinguishes a dev-tree build
(`native/<plat>/clide`) from a packaged install — surfaced as an info note on
a checkout rather than treated as a clean install or prompting a reinstall.
(T-256)
- Command palette (⌘⇧P) now fuzzy-matches command titles (subsequence, not
just substring) and floats recently-used commands to the top. (T-23)
- ⌘K now opens a **Settings** modal instead of a theme-only picker. Its first
@@ -48,6 +48,12 @@ class CliInstallExtension extends ClideExtension {
'in PATH" to replace it.',
title: 'clide CLI is stale',
);
case CliInstallState.devTree:
ctx.notify.info(
'`clide` on your PATH is the dev-tree build, not a packaged install '
'— fine for development; rebuild it with `make clide-cli`.',
title: 'clide CLI: dev build',
);
case CliInstallState.installed:
break;
}
+33 -4
View File
@@ -23,7 +23,14 @@ enum CliInstallState {
/// client — running it launches a second app instead of querying.
staleGui,
/// `clide` resolves to something that is not the GUI — assumed good.
/// `clide` resolves to a dev-tree build artifact (`native/<plat>/clide`),
/// not a packaged production install (T-256). A working client — fine on a
/// dev checkout — but worth surfacing so it isn't mistaken for a real
/// install, and so a rebuild/reinstall isn't silently skipped.
devTree,
/// `clide` resolves to something that is not the GUI or a dev build —
/// assumed a good production install.
installed,
}
@@ -39,8 +46,10 @@ class CliInstallStatus {
/// Where [pathEntry] resolves to after following symlinks, if any.
final String? resolvedTarget;
/// True when the user should (re)install — missing or stale.
bool get needsInstall => state != CliInstallState.installed;
/// True when the user should (re)install — `clide` is absent or points at
/// the GUI. A dev-tree build is intentional on a checkout, so it doesn't
/// trigger an install prompt.
bool get needsInstall => state == CliInstallState.missing || state == CliInstallState.staleGui;
}
/// Result of [CliInstaller.install].
@@ -50,6 +59,7 @@ class CliInstallResult {
required this.message,
this.installedPath,
this.onPath = true,
this.fromDevTree = false,
});
final bool ok;
@@ -59,6 +69,10 @@ class CliInstallResult {
/// False when [installedPath]'s directory is not itself on PATH (the copy
/// succeeded but the user must add the dir to PATH to reach `clide`).
final bool onPath;
/// True when the source copied was a dev-tree build artifact rather than the
/// bundled production client (T-256) — a packaged install ships the latter.
final bool fromDevTree;
}
/// Copies the bundled C client onto PATH and reports what `clide` currently
@@ -105,6 +119,9 @@ class CliInstaller {
if (_isGui(resolved)) {
return CliInstallStatus(CliInstallState.staleGui, pathEntry: found, resolvedTarget: resolved);
}
if (isDevTreeClient(resolved)) {
return CliInstallStatus(CliInstallState.devTree, pathEntry: found, resolvedTarget: resolved);
}
return CliInstallStatus(CliInstallState.installed, pathEntry: found, resolvedTarget: resolved);
}
@@ -134,11 +151,14 @@ class CliInstaller {
return CliInstallResult(ok: false, message: 'Install failed: ${e.message}', installedPath: dest);
}
final onPath = _dirOnPath(installDir);
final fromDevTree = isDevTreeClient(_resolve(src));
final base = onPath ? 'Installed clide to $dest' : 'Installed clide to $dest — add $installDir to your PATH to use it.';
return CliInstallResult(
ok: true,
installedPath: dest,
onPath: onPath,
message: onPath ? 'Installed clide to $dest' : 'Installed clide to $dest — add $installDir to your PATH to use it.',
fromDevTree: fromDevTree,
message: fromDevTree ? '$base (from the dev-tree build; a packaged install ships the production client).' : base,
);
}
@@ -204,6 +224,15 @@ class CliInstaller {
}
}
final RegExp _devTreeClient = RegExp(r'(^|/)native/(linux|macos)-(x64|arm64)/clide$');
/// True when [path] is a dev-tree C-client build artifact —
/// `native/<platform>/clide`, the Makefile's `CLIDE_CLI_BIN` output. On a clide
/// checkout `make run` points `CLIDE_CLI_BIN` there and a dev may put it on
/// PATH; it's a working client but not a packaged production install, so it's
/// classified separately (T-256) rather than as a clean install.
bool isDevTreeClient(String path) => _devTreeClient.hasMatch(path);
/// Expand a `PATH` value. Mirrors `toolchain_paths.dart`: macOS GUI apps
/// launch with a sparse PATH that omits the usual user/homebrew bins, so on
/// macOS we prepend those (de-duplicated) before scanning. A top-level,
@@ -85,6 +85,22 @@ void main() {
expect(notes.first.title, 'clide CLI is stale');
});
test('notes a dev-tree build on activation (info, not a warning) (T-256)', () async {
final dev = touchExec('${tmp.path}/native/linux-x64/clide').path;
final binDir = Directory('${tmp.path}/bin')..createSync();
Link('${binDir.path}/clide').createSync(dev);
await boot(CliInstaller(
resolvedExecutable: '${tmp.path}/gui/clide',
env: {'PATH': binDir.path},
bundledClientCandidates: const [],
installDir: binDir.path,
));
final notes = f.services.notify.active;
expect(notes, isNotEmpty);
expect(notes.first.level, NotificationLevel.info);
expect(notes.first.title, 'clide CLI: dev build');
});
test('does not warn when clide is already installed', () async {
final binDir = Directory('${tmp.path}/bin')..createSync();
touchExec('${binDir.path}/clide');
+55
View File
@@ -108,6 +108,34 @@ void main() {
);
expect(i.inspect().state, CliInstallState.staleGui);
});
test('devTree when clide resolves to a native/<plat>/clide build (T-256)', () {
final dev = touchExec('${tmp.path}/native/linux-x64/clide').path;
final binDir = Directory('${tmp.path}/bin')..createSync();
Link('${binDir.path}/clide').createSync(dev);
final i = installer(
resolvedExecutable: '${tmp.path}/gui/clide',
env: {'PATH': binDir.path},
);
final s = i.inspect();
expect(s.state, CliInstallState.devTree);
// A dev build is intentional — it doesn't prompt a reinstall.
expect(s.needsInstall, isFalse);
});
});
group('isDevTreeClient', () {
test('matches the native/<platform>/clide build artifacts', () {
expect(isDevTreeClient('/repo/native/linux-x64/clide'), isTrue);
expect(isDevTreeClient('/repo/native/macos-arm64/clide'), isTrue);
expect(isDevTreeClient('native/linux-arm64/clide'), isTrue);
});
test('does not match installed or bundled paths', () {
expect(isDevTreeClient('/home/x/.local/bin/clide'), isFalse);
expect(isDevTreeClient('/usr/local/bin/clide'), isFalse);
expect(isDevTreeClient('/opt/clide/bundle/clide-cli'), isFalse);
});
});
group('defaults', () {
@@ -191,6 +219,33 @@ void main() {
}
});
test('flags fromDevTree when the source is a dev-tree build (T-256)', () {
final src = touchExec('${tmp.path}/native/linux-x64/clide');
final binDir = '${tmp.path}/bin';
final i = installer(
resolvedExecutable: '${tmp.path}/gui/clide',
env: {'PATH': binDir},
candidates: [src.path],
installDir: binDir,
);
final r = i.install();
expect(r.ok, isTrue);
expect(r.fromDevTree, isTrue);
expect(r.message, contains('dev-tree build'));
});
test('a bundled (non-dev) source is not flagged fromDevTree', () {
final src = touchExec('${tmp.path}/bundle/clide-cli');
final binDir = '${tmp.path}/bin';
final i = installer(
resolvedExecutable: '${tmp.path}/gui/clide',
env: {'PATH': binDir},
candidates: [src.path],
installDir: binDir,
);
expect(i.install().fromDevTree, isFalse);
});
test('overwrites a stale symlink rather than following it', () {
final src = touchExec('${tmp.path}/bundle/clide-cli', contents: 'NEW');
final gui = touchExec('${tmp.path}/gui/clide', contents: 'GUI').path;