add in-app "Install clide command in PATH" affordance (T-212)
A command-palette / `clide` CLI verb (`clide.installCli`) copies the bundled C client into ~/.local/bin, VS Code "Install code command" style, so a user who runs the app without `make install` can still reach the CLI from a shell. On launch the app detects when `clide` is missing from PATH or resolves to a stale symlink into the Flutter GUI bundle (the dogfood footgun: a bare `clide` launched a second app instead of querying the socket) and notifies with a pointer to the command. `make build` now ships the C client inside the app bundle so the affordance can self-install from it; `make run` points it at the dev-tree client via CLIDE_CLI_BIN. Detection and the copy live in the Flutter-free CliInstaller (kernel/src/cli_install.dart) so they run under unit tests without a real install. Closes epic T-209 (ship the clide CLI on PATH). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -460,3 +460,5 @@ list, ship our own curated, on-brand list instead. Document the decision.
|
||||
- Touch point today is the `widget.busy` branch in `claude_composer.dart`; consider
|
||||
whether the rotating-word state belongs there or in the session/orchestrator layer.
|
||||
- Add a widget/golden test for the animated states (bounded pumps — no real timers).', NULL, '2026-06-06 09:57:12', '2026-06-06 09:57:12', '2026-06-06 09:57:12', NULL, '586f397ed5d0f14a0e7d510237d54ea2', 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-212', 'status', 'in_progress', 'done', NULL, '2026-06-06 10:02:03', '2026-06-06 10:02:03', '2026-06-06 10:02:03', NULL, 'c0174026e93366bda336a58bfdaec999', 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-209', 'status', 'in_progress', 'done', NULL, '2026-06-06 10:02:06', '2026-06-06 10:02:06', '2026-06-06 10:02:06', NULL, 'b66cdca1e3acedc775ec2e25e9509cd6', 1) ON CONFLICT(hash) DO NOTHING;
|
||||
|
||||
@@ -985,3 +985,11 @@ list, ship our own curated, on-brand list instead. Document the decision.
|
||||
- Touch point today is the `widget.busy` branch in `claude_composer.dart`; consider
|
||||
whether the rotating-word state belongs there or in the session/orchestrator layer.
|
||||
- Add a widget/golden test for the animated states (bounded pumps — no real timers).', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-06 09:56:23', '2026-06-06 09:57:12', NULL, '17a3feaae4f5481f212578f0174fd09a', 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-212', 'task', 'T-209', 'In-app ''Install clide command in PATH'' affordance', 'VS Code-style: the GUI offers to install the `clide` shell command on PATH, for users who run the .app without `make install`. A command + command-palette entry that copies/symlinks the bundled C client to a PATH dir and reports success/failure. Acceptance: invoking it makes `clide` resolve on PATH from a fresh shell.
|
||||
|
||||
Refinement (2026-06-03, from live dogfooding): scope should include PROACTIVE detection on launch, not just a palette command. When the clide IDE starts in a repo, check whether ''clide'' resolves on PATH AND points to the C client (not a stale symlink to the GUI bundle runner) -- we hit exactly this: ~/.local/bin/clide was a May-6 symlink to ~/.local/lib/clide/clide (the Flutter GUI), so a bare ''clide pane list'' launched a second app instead of querying. If missing or stale, prompt/offer to install (copy the bundled C client to a PATH dir, VS Code ''Install code command'' style) and report success. This is what lets a fresh agent actually reach the CLI (D-83 names the hosted session primary, but an external agent benefits too). Detecting ''stale GUI symlink'' specifically: the target should be an ELF/Mach-O executable, not a symlink into the bundle. Alternative path the user raised: instead of/alongside this, make the /ide MCP surface reachable (T-225) -- but CLI is primary per D-68.', 'done', 'medium', NULL, NULL, NULL, '2026-06-02 18:13:52', '2026-06-06 10:02:03', NULL, 'cc452389e195d3efa2f0244e74444768', 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-209', 'epic', 'T-208', 'Ship the clide CLI on PATH', 'Gap 1 (BLOCKER) from self-analysis.md. The C client native/clide-cli/clide.c (T-126, done) is the real CLI, but there is no supported way for `clide` to land on an agent''s PATH:
|
||||
- `make clide-cli` (Makefile:284) is a non-default target; `build` (Makefile:169) does not depend on it, so the binary is never produced by a normal build (native/macos-arm64/clide had never been built).
|
||||
- `make install` (Makefile:195) copies the GUI bundle and `ln -sf $(INSTALL_PREFIX)/clide/clide $(INSTALL_DIR)/clide` (Makefile:200) symlinks the FLUTTER GUI runner — not the C client — on BOTH macOS and Linux (on macOS that path doesn''t even resolve inside clide.app).
|
||||
|
||||
Net: `which clide` -> not found, `clide ping` -> exit 127. The CLI-first contract (D-1) has no delivery mechanism. Related to the broader installer epic T-46, but that is the GUI/runtime installer; this epic is specifically C-client-on-PATH delivery.', 'done', 'high', NULL, NULL, 'D-1', '2026-06-02 18:13:52', '2026-06-06 10:02:06', NULL, '7a173eae965eedcfaf742707873651aa', 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);
|
||||
|
||||
@@ -18,6 +18,15 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
||||
|
||||
### Added
|
||||
|
||||
- "Install 'clide' command in PATH": a command-palette / `clide` CLI verb
|
||||
(`clide.installCli`) that copies the bundled C client into `~/.local/bin`,
|
||||
VS Code "Install code command" style — so a user who runs the app without
|
||||
`make install` can still reach the CLI from a shell. On launch the app now
|
||||
detects when `clide` is missing from PATH, or resolves to a stale symlink
|
||||
into the Flutter GUI bundle (which would launch a second app instead of
|
||||
querying the socket), and notifies with a pointer to the command. `make
|
||||
build` ships the C client inside the app bundle so the affordance can
|
||||
self-install from it. (T-212)
|
||||
- `clide ui open diff <path>` reveals the diff in a split above the Claude
|
||||
conversation, scrolls to that file and highlights its header — the
|
||||
diff-panel arm of `ui open`. Workspace tabs other than Claude/editor now
|
||||
|
||||
@@ -32,11 +32,14 @@ help: ## Show this help.
|
||||
# -- app (Flutter) -------------------------------------------------------
|
||||
|
||||
.PHONY: run
|
||||
run: gen-build-info ## Launch the Flutter desktop app.
|
||||
run: gen-build-info clide-cli ## Launch the Flutter desktop app.
|
||||
# CLIDE_CLI_BIN points the in-app "Install clide command in PATH"
|
||||
# affordance (T-212) at the dev-tree C client; a packaged build finds it
|
||||
# beside the GUI runner in the bundle instead.
|
||||
ifeq ($(FLUTTER_OS),linux)
|
||||
GDK_BACKEND=x11 LD_LIBRARY_PATH=$(CURDIR)/native/linux-x64$${LD_LIBRARY_PATH:+:$$LD_LIBRARY_PATH} flutter run -d linux --dart-define=CLIDE_PROJECT=$(CURDIR)
|
||||
CLIDE_CLI_BIN=$(CURDIR)/$(CLIDE_CLI_BIN) GDK_BACKEND=x11 LD_LIBRARY_PATH=$(CURDIR)/native/linux-x64$${LD_LIBRARY_PATH:+:$$LD_LIBRARY_PATH} flutter run -d linux --dart-define=CLIDE_PROJECT=$(CURDIR)
|
||||
else
|
||||
flutter run -d $(FLUTTER_OS) --dart-define=CLIDE_PROJECT=$(CURDIR)
|
||||
CLIDE_CLI_BIN=$(CURDIR)/$(CLIDE_CLI_BIN) flutter run -d $(FLUTTER_OS) --dart-define=CLIDE_PROJECT=$(CURDIR)
|
||||
endif
|
||||
|
||||
TESTMODE_CATEGORY ?= all
|
||||
@@ -168,6 +171,8 @@ ui-smoke: ## Build + serve + run Playwright smoke + stop.
|
||||
.PHONY: build
|
||||
build: gen-build-info clide-cli ## flutter build for the current OS (incl. the C CLI client).
|
||||
flutter build $(FLUTTER_OS)
|
||||
@install -m 755 $(CLIDE_CLI_BIN) $(CLI_BUNDLE_DEST)
|
||||
@echo "==> bundled C client at $(CLI_BUNDLE_DEST)"
|
||||
|
||||
.PHONY: build-linux
|
||||
build-linux: gen-build-info ## flutter build linux (desktop bundle).
|
||||
@@ -185,8 +190,12 @@ INSTALL_PREFIX ?= $(HOME)/.local/lib
|
||||
|
||||
ifeq ($(FLUTTER_OS),linux)
|
||||
BUNDLE_DIR := build/linux/x64/release/bundle
|
||||
# The C client ships beside the GUI runner so the in-app "Install clide
|
||||
# command in PATH" affordance (T-212) can self-install from the bundle.
|
||||
CLI_BUNDLE_DEST := $(BUNDLE_DIR)/clide-cli
|
||||
else ifeq ($(FLUTTER_OS),macos)
|
||||
BUNDLE_DIR := build/macos/Build/Products/Release/clide.app
|
||||
CLI_BUNDLE_DEST := $(BUNDLE_DIR)/Contents/MacOS/clide-cli
|
||||
endif
|
||||
|
||||
ICON_SIZES := 16 32 48 128 192 256 512
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export 'src/extension.dart';
|
||||
@@ -0,0 +1,84 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/extension/extension.dart';
|
||||
import 'package:clide/kernel/src/cli_install.dart';
|
||||
|
||||
/// VS Code-style "Install 'clide' command in PATH" affordance (T-212).
|
||||
///
|
||||
/// Registers a palette/CLI command that copies the bundled C client into a
|
||||
/// PATH dir, and on activation proactively warns when `clide` is missing from
|
||||
/// PATH or — the dogfood footgun — resolves to a stale symlink into the
|
||||
/// Flutter GUI bundle instead of the C client. Detection and the copy live in
|
||||
/// the Flutter-free [CliInstaller]; this extension only wires it to the
|
||||
/// command and notification surfaces.
|
||||
class CliInstallExtension extends ClideExtension {
|
||||
CliInstallExtension({CliInstaller? installer}) : _installer = installer;
|
||||
|
||||
CliInstaller? _installer;
|
||||
ClideExtensionContext? _ctx;
|
||||
|
||||
CliInstaller get _resolved => _installer ??= CliInstaller(resolvedExecutable: Platform.resolvedExecutable);
|
||||
|
||||
@override
|
||||
String get id => 'builtin.cli-install';
|
||||
@override
|
||||
String get title => 'CLI Install';
|
||||
@override
|
||||
String get version => '0.1.0';
|
||||
|
||||
@override
|
||||
Future<void> activate(ClideExtensionContext ctx) async {
|
||||
_ctx = ctx;
|
||||
// Proactive launch-time detection (desktop only). Non-modal: we notify
|
||||
// and point at the command rather than auto-installing — no surprise
|
||||
// filesystem writes (interaction-zone discipline, D-78).
|
||||
if (!(Platform.isLinux || Platform.isMacOS)) return;
|
||||
switch (_resolved.inspect().state) {
|
||||
case CliInstallState.missing:
|
||||
ctx.notify.warn(
|
||||
'The `clide` command is not on your PATH. Run "clide: Install '
|
||||
'command in PATH" from the command palette to reach it from a shell.',
|
||||
title: 'clide CLI not installed',
|
||||
);
|
||||
case CliInstallState.staleGui:
|
||||
ctx.notify.warn(
|
||||
'`clide` on your PATH points at the GUI app, not the CLI client — '
|
||||
'a bare `clide` launches a second app. Run "clide: Install command '
|
||||
'in PATH" to replace it.',
|
||||
title: 'clide CLI is stale',
|
||||
);
|
||||
case CliInstallState.installed:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
List<ContributionPoint> get contributions => [
|
||||
CommandContribution(
|
||||
id: 'clide.installCli',
|
||||
command: 'clide.installCli',
|
||||
title: "clide: Install 'clide' command in PATH",
|
||||
run: (_) async {
|
||||
final r = _resolved.install();
|
||||
final ctx = _ctx;
|
||||
if (r.ok) {
|
||||
ctx?.notify.success(r.message, title: 'clide CLI installed');
|
||||
return IpcResponse.ok(id: '', data: {
|
||||
'installed': r.installedPath,
|
||||
'onPath': r.onPath,
|
||||
});
|
||||
}
|
||||
ctx?.notify.error(r.message, title: 'clide CLI install failed');
|
||||
return IpcResponse.err(
|
||||
id: '',
|
||||
error: IpcError(
|
||||
code: IpcExitCode.toolError,
|
||||
kind: IpcErrorKind.toolError,
|
||||
message: r.message,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
/// Flutter-free logic for the "Install clide command in PATH" affordance
|
||||
/// (T-212).
|
||||
///
|
||||
/// Detects whether the `clide` shell command resolves on PATH and points at
|
||||
/// the real C client — not a stale symlink to the Flutter GUI runner, the
|
||||
/// exact footgun a live dogfood hit: a `~/.local/bin/clide` symlink into the
|
||||
/// GUI bundle launched a *second* app instead of querying the IPC socket. The
|
||||
/// installer copies the bundled C client into a PATH dir, VS Code
|
||||
/// "Install code command" style.
|
||||
///
|
||||
/// Kept Flutter-free (only `dart:io`) so it runs under `dart test`; the
|
||||
/// builtin extension wraps it with the command + notification surfaces.
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
/// State of the `clide` shell command relative to the running GUI.
|
||||
enum CliInstallState {
|
||||
/// No `clide` resolves on PATH.
|
||||
missing,
|
||||
|
||||
/// `clide` resolves but points at the Flutter GUI runner, not the C
|
||||
/// client — running it launches a second app instead of querying.
|
||||
staleGui,
|
||||
|
||||
/// `clide` resolves to something that is not the GUI — assumed good.
|
||||
installed,
|
||||
}
|
||||
|
||||
/// Result of [CliInstaller.inspect]: what `clide` on PATH points at.
|
||||
class CliInstallStatus {
|
||||
const CliInstallStatus(this.state, {this.pathEntry, this.resolvedTarget});
|
||||
|
||||
final CliInstallState state;
|
||||
|
||||
/// The `clide` entry found on PATH (the symlink/file itself), if any.
|
||||
final String? pathEntry;
|
||||
|
||||
/// 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;
|
||||
}
|
||||
|
||||
/// Result of [CliInstaller.install].
|
||||
class CliInstallResult {
|
||||
const CliInstallResult({
|
||||
required this.ok,
|
||||
required this.message,
|
||||
this.installedPath,
|
||||
this.onPath = true,
|
||||
});
|
||||
|
||||
final bool ok;
|
||||
final String message;
|
||||
final String? installedPath;
|
||||
|
||||
/// 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;
|
||||
}
|
||||
|
||||
/// Copies the bundled C client onto PATH and reports what `clide` currently
|
||||
/// resolves to. Every external dependency (the running executable, the
|
||||
/// environment, candidate client locations, the target dir) is injectable so
|
||||
/// the logic is unit-testable without a real install.
|
||||
class CliInstaller {
|
||||
CliInstaller({
|
||||
required this.resolvedExecutable,
|
||||
Map<String, String>? env,
|
||||
List<String>? bundledClientCandidates,
|
||||
String? installDir,
|
||||
}) : env = env ?? Platform.environment,
|
||||
bundledClientCandidates = bundledClientCandidates ?? _defaultBundledCandidates(resolvedExecutable, env ?? Platform.environment),
|
||||
installDir = installDir ?? _defaultInstallDir(env ?? Platform.environment);
|
||||
|
||||
/// Path to the running Flutter GUI executable
|
||||
/// (`Platform.resolvedExecutable`).
|
||||
final String resolvedExecutable;
|
||||
|
||||
final Map<String, String> env;
|
||||
|
||||
/// Ordered locations to look for the bundled C client to install from.
|
||||
final List<String> bundledClientCandidates;
|
||||
|
||||
/// Directory the C client is installed into (created if absent).
|
||||
final String installDir;
|
||||
|
||||
/// First bundled C client candidate that exists, or null.
|
||||
String? findBundledClient() {
|
||||
for (final c in bundledClientCandidates) {
|
||||
if (c.isNotEmpty && File(c).existsSync()) return c;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Inspect the current state of `clide` on PATH. Filesystem-only — never
|
||||
/// execs the binary, since exec'ing a stale GUI symlink is exactly the bug
|
||||
/// this guards against (it would launch a second app).
|
||||
CliInstallStatus inspect() {
|
||||
final found = _findOnPath('clide');
|
||||
if (found == null) return const CliInstallStatus(CliInstallState.missing);
|
||||
final resolved = _resolve(found);
|
||||
if (_isGui(resolved)) {
|
||||
return CliInstallStatus(CliInstallState.staleGui, pathEntry: found, resolvedTarget: resolved);
|
||||
}
|
||||
return CliInstallStatus(CliInstallState.installed, pathEntry: found, resolvedTarget: resolved);
|
||||
}
|
||||
|
||||
/// Copy the bundled C client to `<installDir>/clide` (overwriting any stale
|
||||
/// entry) and mark it executable.
|
||||
CliInstallResult install() {
|
||||
final src = findBundledClient();
|
||||
if (src == null) {
|
||||
return const CliInstallResult(
|
||||
ok: false,
|
||||
message: 'No bundled clide client found to install. Build with '
|
||||
'`make build` so the C client ships inside the app bundle.',
|
||||
);
|
||||
}
|
||||
final dest = '${_normalize(installDir)}/clide';
|
||||
try {
|
||||
Directory(installDir).createSync(recursive: true);
|
||||
// Delete any existing entry first so a stale symlink (e.g. one into
|
||||
// the GUI bundle) is replaced, not followed. typeSync never throws —
|
||||
// a missing path reports notFound.
|
||||
if (FileSystemEntity.typeSync(dest, followLinks: false) != FileSystemEntityType.notFound) {
|
||||
File(dest).deleteSync();
|
||||
}
|
||||
File(src).copySync(dest);
|
||||
_chmodExec(dest);
|
||||
} on FileSystemException catch (e) {
|
||||
return CliInstallResult(ok: false, message: 'Install failed: ${e.message}', installedPath: dest);
|
||||
}
|
||||
final onPath = _dirOnPath(installDir);
|
||||
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.',
|
||||
);
|
||||
}
|
||||
|
||||
/// True when [path] is, or sits inside, the Flutter GUI bundle. The C
|
||||
/// client is a standalone binary; the GUI runner ships alongside Flutter's
|
||||
/// asset payload (`data/flutter_assets`) or inside a macOS `.app`.
|
||||
bool _isGui(String path) {
|
||||
if (path == _resolve(resolvedExecutable)) return true;
|
||||
final dir = File(path).parent.path;
|
||||
if (Directory('$dir/data/flutter_assets').existsSync()) return true;
|
||||
if (path.contains('.app/Contents/')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
String _resolve(String path) {
|
||||
try {
|
||||
return File(path).resolveSymbolicLinksSync();
|
||||
} on FileSystemException {
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
void _chmodExec(String path) {
|
||||
if (Platform.isWindows) return;
|
||||
// dart:io has no chmod; copySync drops the source's +x under umask.
|
||||
Process.runSync('chmod', ['755', path]);
|
||||
}
|
||||
|
||||
bool _dirOnPath(String dir) {
|
||||
final norm = _normalize(dir);
|
||||
return _expandedPath().split(':').any((d) => d.isNotEmpty && _normalize(d) == norm);
|
||||
}
|
||||
|
||||
String _normalize(String p) => p.length > 1 && p.endsWith('/') ? p.substring(0, p.length - 1) : p;
|
||||
|
||||
String? _findOnPath(String name) {
|
||||
for (final dir in _expandedPath().split(':')) {
|
||||
if (dir.isEmpty) continue;
|
||||
final f = File('$dir/$name');
|
||||
if (f.existsSync()) return f.path;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String _expandedPath() => expandedPath(
|
||||
env['PATH'] ?? '',
|
||||
macOS: Platform.isMacOS,
|
||||
home: env['HOME'] ?? '',
|
||||
);
|
||||
|
||||
static String _defaultInstallDir(Map<String, String> env) => '${env['HOME'] ?? ''}/.local/bin';
|
||||
|
||||
/// Where to find the C client to install from: a `CLIDE_CLI_BIN` dev
|
||||
/// override first, then `<exe-dir>/clide-cli` — where `make build` drops it
|
||||
/// inside the bundle (next to the GUI runner on Linux, in
|
||||
/// `Contents/MacOS/` on macOS).
|
||||
static List<String> _defaultBundledCandidates(String resolvedExecutable, Map<String, String> env) {
|
||||
final exeDir = File(resolvedExecutable).parent.path;
|
||||
return [
|
||||
if ((env['CLIDE_CLI_BIN'] ?? '').isNotEmpty) env['CLIDE_CLI_BIN']!,
|
||||
'$exeDir/clide-cli',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
/// platform-parameterized function so both branches are testable off-platform.
|
||||
String expandedPath(String base, {required bool macOS, String home = ''}) {
|
||||
if (!macOS) return base;
|
||||
final extras = <String>[
|
||||
if (home.isNotEmpty) '$home/.local/bin',
|
||||
'/opt/homebrew/bin',
|
||||
'/opt/homebrew/sbin',
|
||||
'/usr/local/bin',
|
||||
];
|
||||
final existing = base.split(':').toSet();
|
||||
final missing = extras.where((p) => !existing.contains(p));
|
||||
if (missing.isEmpty) return base;
|
||||
return [...missing, ...existing].join(':');
|
||||
}
|
||||
+3
-1
@@ -5,6 +5,7 @@ import 'package:clide/test_app.dart';
|
||||
import 'package:clide/builtin/canvas/canvas.dart';
|
||||
import 'package:clide/builtin/claude/claude.dart';
|
||||
import 'package:clide/builtin/claude_control/claude_control.dart';
|
||||
import 'package:clide/builtin/cli_install/cli_install.dart';
|
||||
import 'package:clide/builtin/decisions/decisions.dart';
|
||||
import 'package:clide/builtin/default_layout/default_layout.dart';
|
||||
import 'package:clide/builtin/diff/diff.dart';
|
||||
@@ -375,7 +376,8 @@ Future<void> main() async {
|
||||
..register(SettingsUiExtension())
|
||||
..register(ExtensionsUiExtension())
|
||||
..register(KeybindingsUiExtension())
|
||||
..register(ClaudeControlExtension());
|
||||
..register(ClaudeControlExtension())
|
||||
..register(CliInstallExtension());
|
||||
|
||||
await services.extensions.activateAll();
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
/// T-212: the cli-install extension wires the Flutter-free [CliInstaller] to
|
||||
/// a palette/CLI command and proactive launch-time notifications.
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/builtin/cli_install/cli_install.dart';
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/kernel/src/cli_install.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../helpers/kernel_fixture.dart';
|
||||
|
||||
void main() {
|
||||
late KernelFixture f;
|
||||
late Directory tmp;
|
||||
|
||||
setUp(() async {
|
||||
f = await KernelFixture.create();
|
||||
tmp = Directory.systemTemp.createTempSync('clide_cli_ext_');
|
||||
});
|
||||
tearDown(() async {
|
||||
await f.dispose();
|
||||
tmp.deleteSync(recursive: true);
|
||||
});
|
||||
|
||||
File touchExec(String path, {String contents = '#!/bin/sh\n'}) {
|
||||
final file = File(path)
|
||||
..createSync(recursive: true)
|
||||
..writeAsStringSync(contents);
|
||||
if (!Platform.isWindows) Process.runSync('chmod', ['755', path]);
|
||||
return file;
|
||||
}
|
||||
|
||||
/// Register + activate the extension with an injected installer.
|
||||
Future<void> boot(CliInstaller installer) async {
|
||||
f.services.extensions.register(CliInstallExtension(installer: installer));
|
||||
await f.services.extensions.activate('builtin.cli-install');
|
||||
}
|
||||
|
||||
test('identifies itself', () {
|
||||
final ext = CliInstallExtension();
|
||||
expect(ext.id, 'builtin.cli-install');
|
||||
expect(ext.title, 'CLI Install');
|
||||
expect(ext.version, '0.1.0');
|
||||
});
|
||||
|
||||
test('contributes the install command', () async {
|
||||
await boot(CliInstaller(
|
||||
resolvedExecutable: '${tmp.path}/gui/clide',
|
||||
env: {'PATH': '${tmp.path}/bin'},
|
||||
bundledClientCandidates: const [],
|
||||
installDir: '${tmp.path}/bin',
|
||||
));
|
||||
expect(f.services.commands.get('clide.installCli'), isNotNull);
|
||||
});
|
||||
|
||||
test('warns on activation when clide is missing from PATH', () async {
|
||||
await boot(CliInstaller(
|
||||
resolvedExecutable: '${tmp.path}/gui/clide',
|
||||
env: {'PATH': '${tmp.path}/empty'},
|
||||
bundledClientCandidates: const [],
|
||||
installDir: '${tmp.path}/bin',
|
||||
));
|
||||
final notes = f.services.notify.active;
|
||||
expect(notes, isNotEmpty);
|
||||
expect(notes.first.level, NotificationLevel.warning);
|
||||
expect(notes.first.title, 'clide CLI not installed');
|
||||
});
|
||||
|
||||
test('does not warn when clide is already installed', () async {
|
||||
final binDir = Directory('${tmp.path}/bin')..createSync();
|
||||
touchExec('${binDir.path}/clide');
|
||||
await boot(CliInstaller(
|
||||
resolvedExecutable: '${tmp.path}/gui/clide',
|
||||
env: {'PATH': binDir.path},
|
||||
bundledClientCandidates: const [],
|
||||
installDir: binDir.path,
|
||||
));
|
||||
expect(f.services.notify.active, isEmpty);
|
||||
});
|
||||
|
||||
test('running the command installs the client and reports success', () async {
|
||||
final src = touchExec('${tmp.path}/bundle/clide-cli');
|
||||
final binDir = '${tmp.path}/bin';
|
||||
await boot(CliInstaller(
|
||||
resolvedExecutable: '${tmp.path}/gui/clide',
|
||||
env: {'PATH': binDir},
|
||||
bundledClientCandidates: [src.path],
|
||||
installDir: binDir,
|
||||
));
|
||||
final r = await f.services.commands.execute('clide.installCli');
|
||||
expect(r.ok, isTrue);
|
||||
expect(r.data['installed'], '$binDir/clide');
|
||||
expect(File('$binDir/clide').existsSync(), isTrue);
|
||||
expect(
|
||||
f.services.notify.active.any((n) => n.level == NotificationLevel.success && n.title == 'clide CLI installed'),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('running the command surfaces a tool error when nothing to install', () async {
|
||||
await boot(CliInstaller(
|
||||
resolvedExecutable: '${tmp.path}/gui/clide',
|
||||
env: {'PATH': '${tmp.path}/bin'},
|
||||
bundledClientCandidates: const [],
|
||||
installDir: '${tmp.path}/bin',
|
||||
));
|
||||
final r = await f.services.commands.execute('clide.installCli');
|
||||
expect(r.ok, isFalse);
|
||||
expect(r.error!.kind, IpcErrorKind.toolError);
|
||||
expect(
|
||||
f.services.notify.active.any((n) => n.level == NotificationLevel.error),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/kernel/src/cli_install.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
/// Pure-logic tests for the "Install clide command in PATH" affordance
|
||||
/// (T-212). All I/O is synchronous against temp dirs — every external
|
||||
/// dependency of [CliInstaller] is injected, so no real install is touched.
|
||||
///
|
||||
/// Linux-only assertions: the resolver mirrors `toolchain_paths.dart`, whose
|
||||
/// macOS branch injects homebrew/local bins into PATH; gating to Linux keeps
|
||||
/// the expanded-PATH behaviour deterministic. The CI host is Linux.
|
||||
void main() {
|
||||
late Directory tmp;
|
||||
|
||||
setUp(() => tmp = Directory.systemTemp.createTempSync('clide_cli_install_'));
|
||||
tearDown(() => tmp.deleteSync(recursive: true));
|
||||
|
||||
/// A stand-in executable file at [path], created with the given [contents].
|
||||
File touchExec(String path, {String contents = '#!/bin/sh\n'}) {
|
||||
final f = File(path)
|
||||
..createSync(recursive: true)
|
||||
..writeAsStringSync(contents);
|
||||
if (!Platform.isWindows) Process.runSync('chmod', ['755', path]);
|
||||
return f;
|
||||
}
|
||||
|
||||
CliInstaller installer({
|
||||
required String resolvedExecutable,
|
||||
required Map<String, String> env,
|
||||
List<String>? candidates,
|
||||
String? installDir,
|
||||
}) =>
|
||||
CliInstaller(
|
||||
resolvedExecutable: resolvedExecutable,
|
||||
env: env,
|
||||
bundledClientCandidates: candidates,
|
||||
installDir: installDir,
|
||||
);
|
||||
|
||||
group('findBundledClient', () {
|
||||
test('returns the first candidate that exists', () {
|
||||
final present = '${tmp.path}/clide-cli';
|
||||
touchExec(present);
|
||||
final i = installer(
|
||||
resolvedExecutable: '${tmp.path}/clide',
|
||||
env: {'PATH': ''},
|
||||
candidates: ['${tmp.path}/missing', present],
|
||||
);
|
||||
expect(i.findBundledClient(), present);
|
||||
});
|
||||
|
||||
test('returns null when no candidate exists', () {
|
||||
final i = installer(
|
||||
resolvedExecutable: '${tmp.path}/clide',
|
||||
env: {'PATH': ''},
|
||||
candidates: ['${tmp.path}/nope'],
|
||||
);
|
||||
expect(i.findBundledClient(), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('inspect', () {
|
||||
test('missing when no clide on PATH', () {
|
||||
final binDir = Directory('${tmp.path}/bin')..createSync();
|
||||
final i = installer(
|
||||
resolvedExecutable: '${tmp.path}/gui/clide',
|
||||
env: {'PATH': binDir.path},
|
||||
);
|
||||
expect(i.inspect().state, CliInstallState.missing);
|
||||
});
|
||||
|
||||
test('installed when clide is a plain non-GUI binary', () {
|
||||
final binDir = Directory('${tmp.path}/bin')..createSync();
|
||||
touchExec('${binDir.path}/clide');
|
||||
final i = installer(
|
||||
resolvedExecutable: '${tmp.path}/gui/clide',
|
||||
env: {'PATH': binDir.path},
|
||||
);
|
||||
final s = i.inspect();
|
||||
expect(s.state, CliInstallState.installed);
|
||||
expect(s.pathEntry, '${binDir.path}/clide');
|
||||
});
|
||||
|
||||
test('staleGui when clide symlinks to the running GUI executable', () {
|
||||
final gui = touchExec('${tmp.path}/gui/clide').path;
|
||||
final binDir = Directory('${tmp.path}/bin')..createSync();
|
||||
Link('${binDir.path}/clide').createSync(gui);
|
||||
final i = installer(
|
||||
resolvedExecutable: gui,
|
||||
env: {'PATH': binDir.path},
|
||||
);
|
||||
final s = i.inspect();
|
||||
expect(s.state, CliInstallState.staleGui);
|
||||
expect(s.needsInstall, isTrue);
|
||||
});
|
||||
|
||||
test('staleGui when clide sits beside a flutter_assets payload', () {
|
||||
// A GUI bundle: runner next to data/flutter_assets/.
|
||||
final bundle = Directory('${tmp.path}/bundle')..createSync();
|
||||
Directory('${bundle.path}/data/flutter_assets').createSync(recursive: true);
|
||||
touchExec('${bundle.path}/clide');
|
||||
final binDir = Directory('${tmp.path}/bin')..createSync();
|
||||
Link('${binDir.path}/clide').createSync('${bundle.path}/clide');
|
||||
final i = installer(
|
||||
resolvedExecutable: '${tmp.path}/other/clide', // unrelated GUI path
|
||||
env: {'PATH': binDir.path},
|
||||
);
|
||||
expect(i.inspect().state, CliInstallState.staleGui);
|
||||
});
|
||||
});
|
||||
|
||||
group('defaults', () {
|
||||
test('install dir and bundled candidates derive from env + exe dir', () {
|
||||
final i = CliInstaller(
|
||||
resolvedExecutable: '/opt/clide/bundle/clide',
|
||||
env: const {'HOME': '/home/dev', 'CLIDE_CLI_BIN': '/dev/tree/clide'},
|
||||
);
|
||||
expect(i.installDir, '/home/dev/.local/bin');
|
||||
// CLIDE_CLI_BIN override first, then the in-bundle path.
|
||||
expect(i.bundledClientCandidates, ['/dev/tree/clide', '/opt/clide/bundle/clide-cli']);
|
||||
});
|
||||
|
||||
test('omits the CLIDE_CLI_BIN candidate when unset', () {
|
||||
final i = CliInstaller(
|
||||
resolvedExecutable: '/opt/clide/bundle/clide',
|
||||
env: const {'HOME': '/home/dev'},
|
||||
);
|
||||
expect(i.bundledClientCandidates, ['/opt/clide/bundle/clide-cli']);
|
||||
});
|
||||
});
|
||||
|
||||
group('expandedPath', () {
|
||||
test('non-macOS returns PATH unchanged', () {
|
||||
expect(expandedPath('/a:/b', macOS: false, home: '/home/x'), '/a:/b');
|
||||
});
|
||||
|
||||
test('macOS prepends missing user + homebrew bins', () {
|
||||
final out = expandedPath('/usr/bin', macOS: true, home: '/home/x').split(':');
|
||||
expect(out, contains('/home/x/.local/bin'));
|
||||
expect(out, contains('/opt/homebrew/bin'));
|
||||
expect(out.last, '/usr/bin');
|
||||
});
|
||||
|
||||
test('macOS does not duplicate entries already on PATH', () {
|
||||
final out = expandedPath('/opt/homebrew/bin:/usr/bin', macOS: true, home: '');
|
||||
expect('/opt/homebrew/bin'.allMatches(out).length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
group('install', () {
|
||||
test('fails clearly when no bundled client is present', () {
|
||||
final i = installer(
|
||||
resolvedExecutable: '${tmp.path}/gui/clide',
|
||||
env: {'PATH': ''},
|
||||
candidates: ['${tmp.path}/none'],
|
||||
installDir: '${tmp.path}/bin',
|
||||
);
|
||||
final r = i.install();
|
||||
expect(r.ok, isFalse);
|
||||
expect(r.message, contains('No bundled clide client'));
|
||||
});
|
||||
|
||||
test('copies the client, marks it executable, reports onPath', () {
|
||||
final src = touchExec('${tmp.path}/bundle/clide-cli', contents: '#!/bin/sh\necho hi\n');
|
||||
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.onPath, isTrue);
|
||||
expect(r.installedPath, '$binDir/clide');
|
||||
final dest = File('$binDir/clide');
|
||||
expect(dest.existsSync(), isTrue);
|
||||
expect(dest.readAsStringSync(), src.readAsStringSync());
|
||||
if (!Platform.isWindows) {
|
||||
final mode = dest.statSync().mode;
|
||||
expect(mode & 0x49, 0x49, reason: 'owner/group/other +x bits set');
|
||||
}
|
||||
});
|
||||
|
||||
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;
|
||||
final binDir = '${tmp.path}/bin';
|
||||
Directory(binDir).createSync();
|
||||
Link('$binDir/clide').createSync(gui); // stale symlink into the GUI
|
||||
final i = installer(
|
||||
resolvedExecutable: gui,
|
||||
env: {'PATH': binDir},
|
||||
candidates: [src.path],
|
||||
installDir: binDir,
|
||||
);
|
||||
expect(i.install().ok, isTrue);
|
||||
// The GUI binary must be untouched; the bin entry is now a real file.
|
||||
expect(File(gui).readAsStringSync(), 'GUI');
|
||||
expect(FileSystemEntity.isLinkSync('$binDir/clide'), isFalse);
|
||||
expect(File('$binDir/clide').readAsStringSync(), 'NEW');
|
||||
});
|
||||
|
||||
test('reports a FileSystemException as a failed result', () {
|
||||
final src = touchExec('${tmp.path}/bundle/clide-cli');
|
||||
// installDir path is occupied by a regular file → createSync throws.
|
||||
final blocker = '${tmp.path}/blocked';
|
||||
File(blocker).writeAsStringSync('not a dir');
|
||||
final i = installer(
|
||||
resolvedExecutable: '${tmp.path}/gui/clide',
|
||||
env: {'PATH': ''},
|
||||
candidates: [src.path],
|
||||
installDir: blocker,
|
||||
);
|
||||
final r = i.install();
|
||||
expect(r.ok, isFalse);
|
||||
expect(r.message, contains('Install failed'));
|
||||
});
|
||||
|
||||
test('reports onPath:false when the install dir is not on PATH', () {
|
||||
final src = touchExec('${tmp.path}/bundle/clide-cli');
|
||||
final installDir = '${tmp.path}/elsewhere';
|
||||
final i = installer(
|
||||
resolvedExecutable: '${tmp.path}/gui/clide',
|
||||
env: {'PATH': '${tmp.path}/somewhere-else'},
|
||||
candidates: [src.path],
|
||||
installDir: installDir,
|
||||
);
|
||||
final r = i.install();
|
||||
expect(r.ok, isTrue);
|
||||
expect(r.onPath, isFalse);
|
||||
expect(r.message, contains('add $installDir to your PATH'));
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user