diff --git a/.claude/skills/clide/SKILL.md b/.claude/skills/clide/SKILL.md new file mode 100644 index 00000000..ac5fa415 --- /dev/null +++ b/.claude/skills/clide/SKILL.md @@ -0,0 +1,63 @@ +--- +name: clide +description: > + Use when you are running inside the clide IDE and want to observe or drive + its live UI — panes, editor, files, git, readers, toasts, layout — through + the `clide` CLI, or to find out what commands clide exposes. clide is the + IDE hosting this session; it puts `clide` on your PATH and a per-workspace + socket in `CLIDE_SOCK`. Start with `clide capabilities` to enumerate the + live tool surface. Triggers: "what can clide do", "drive the clide UI", + "open this in clide", "show the user", "toast", or invoking /clide. +user-invocable: true +allowed-tools: Bash +--- + +# Driving clide from the CLI + +You are (often) running **inside clide** — a Flutter IDE that hosts this +Claude session. It exposes its whole UI surface as a `clide ` +CLI on your PATH, talking to the running app over a per-workspace socket +(`CLIDE_SOCK`). Every UI action the user can take has a CLI verb, and every +verb's effect is observable — that is the parity contract (D-6). So you can +*see what the user sees* and *show the user what you mean*. + +## Discover the surface first — don't hard-code it + +The authoritative, always-current list of commands is the app itself: + +``` +clide capabilities +``` + +It returns JSON: every registered command, split into `subsystem` + `verb`, +with its argument schema (`positional` order + per-arg `type`/`required`/ +constraints) where one is declared. **Sourced from the live dispatcher +registry, so it never drifts.** New panels/verbs appear here the moment they +register — re-run it instead of trusting a remembered list (including this +one). `clide ` with no verb, or an unknown command, prints usage. + +## The two halves of parity + +- **Observe** — read the live UI state: + - `clide status` — one-shot orientation: workspace, git, active editor + buffer + selection, open reader docs, the panes the user sees, layout. + - `clide pane list`, `clide editor active` — narrower snapshots. +- **Drive** — make the UI do something: + - `clide ui open ` — open a doc in a GUI reader + (`tickets`/`decisions` by id, `markdown` by path): "look at this with me." + - `clide ui toast "message" [--severity success|warning|error|info]` — raise + a toast on the user's screen: "tests green", "push failed". + - pane/editor/files/git verbs — see `clide capabilities` for the current set + and their args. + +## Conventions + +- **Slots:** the layout has three content slots — `sidebar` (left), `workspace` + (center, where Claude lives), `context` (right) — plus the bottom `statusbar`. + Many verbs take a slot. +- **Honest failures:** a drive verb with no live GUI returns a `toolError` + ("no live UI to drive"), not a hang. JSON on stdout; exit code conveys + ok/usage/tool error. +- **You only see what flows through clide.** Your own non-`clide` shell work + (plain file reads, `make test`, `git`) is outside clide's view by design + (D-83) — run it *through* `clide …` if you want clide to observe it. diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index 5c1b66da..8cb1db02 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -361,3 +361,4 @@ INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, DONE (2026-06-06). Keybinding + nav were already wired (T-117 binds ctrl/meta+shift+p -> PaletteOpenIntent; _RootShell handles it -> palette.open(); T-100 added arrow/Enter/Esc nav + selected-index). Remaining acceptance implemented now: (1) FUZZY match — PaletteController.filtered() uses a shared subsequence matcher (lib/kernel/src/fuzzy.dart, extracted from quick_open so both share one source of truth), ranked best-score-first; (2) RECENCY — invoked commands float to the top on empty filter and break fuzzy-score ties (in-session MRU). DEFERRED: ''pinned'' commands + cross-session recency persistence need a pin affordance + settings storage — filed as a follow-up. Tests: test/kernel/src/commands/palette_test.dart + test/kernel/src/fuzzy_test.dart.', NULL, '2026-06-06 07:40:43', '2026-06-06 07:40:43', '2026-06-06 07:40:43', NULL, '725ba044d75722c460c7869a2249c386', 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-23', 'status', 'ready', 'done', NULL, '2026-06-06 07:40:43', '2026-06-06 07:40:43', '2026-06-06 07:40:43', NULL, '121337ddae4598659f07bbe16c1ddcc3', 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-250', 'status', 'ready', 'done', NULL, '2026-06-06 07:49:08', '2026-06-06 07:49:08', '2026-06-06 07:49:08', NULL, '0765a2ccbb37deedb402d06709511928', 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-248', 'status', 'ready', 'done', NULL, '2026-06-06 07:57:16', '2026-06-06 07:57:16', '2026-06-06 07:57:16', NULL, 'b36bb78451373dae70a8b5f1f0e16ace', 1) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index 0410dc79..fa247d80 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -804,3 +804,14 @@ Acceptance: - Regression test at the session level: setPermissionMode emits an updated SessionStatus on statusStream. Refs: T-226 (interactive mode badge + Ctrl/Cmd+M), T-181 (bypassPermissions behind confirmed path — keep excluded from the safe cycle), D-78 (interaction-zone / display-only conventions).', 'done', 'high', NULL, NULL, NULL, '2026-06-06 07:26:50', '2026-06-06 07:49:08', NULL, 'e381f4325ef95d18da12a82646d24d9a', 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-248', 'story', NULL, '/clide skill — advertise the clide tool surface to Claude', 'Create a `/clide` skill (none exists today — only legacy templates under legacy/clide/templates/skills/) that advertises the clide CLI surface to Claude so new affordances become discoverable. + +Problem: my knowledge of the clide surface comes from a hand-curated session blurb. There is no runtime discovery — `clide help` is a stub, `clide ` with no verb just prints `usage:`, and the dispatcher''s registered command table (e.g. pane_commands.dart) is never exposed. D-6 guarantees a verb EXISTS for every UI action, but parity != discoverability: a correctly-registered verb is still unreachable if nothing tells me it''s there. + +Scope: +- Self-description first (prereq for a non-rotting skill): add a discovery verb that reflects the live dispatcher registry — e.g. `clide capabilities` (machine-readable JSON: subsystems -> verbs -> arg schema) and/or flesh out `clide help` to enumerate subsystems/verbs. Sourced from the registry so it never drifts. +- `/clide` SKILL.md: trigger description always visible to Claude; body points at the discovery verb rather than hard-coding a verb list, plus conventions (slots: sidebar/workspace/context, pane kinds, focus/spawn/close/write/resize). Thin and always-correct. + +This is what makes T-249 (image viewer) and future panels reachable by Claude the moment they register — no skill edit per panel. + +Refs: D-6 (CLI/event-surface parity). Related: T-249.', 'done', 'medium', NULL, NULL, 'D-6', '2026-06-06 07:21:17', '2026-06-06 07:57:16', NULL, 'f6a976eef4c722612111256e339c8f11', 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); diff --git a/CHANGELOG.md b/CHANGELOG.md index 91371a6c..7bb968f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,9 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. ### Added +- `clide capabilities` lists the live command surface as JSON (every verb with + its subsystem + argument schema), reflected from the dispatcher so it never + drifts. A new `/clide` skill points Claude at it for discovery. (T-248) - Toast notifications for operation feedback: non-modal cards slide in bottom-right, auto-dismiss (errors linger), stack, and are manually dismissable, with success/warning/error/info severities. Components raise diff --git a/lib/src/cli/argv_to_request.dart b/lib/src/cli/argv_to_request.dart index 6b4d5f6d..7154f472 100644 --- a/lib/src/cli/argv_to_request.dart +++ b/lib/src/cli/argv_to_request.dart @@ -25,7 +25,7 @@ import 'package:clide/src/ipc/schema_v1.dart'; /// Umbrella commands — single-token names with no subsystem.verb /// split. Match the IDs the dispatcher exposes directly. -const Set _umbrellaCommands = {'status', 'tail', 'version', 'ping'}; +const Set _umbrellaCommands = {'status', 'tail', 'version', 'ping', 'capabilities'}; /// Sealed result of translating argv. Caller (the IPC server, or the /// C client wrapper in T-126) handles either branch. diff --git a/lib/src/daemon/dispatcher.dart b/lib/src/daemon/dispatcher.dart index 0c42ea07..def3e1d9 100644 --- a/lib/src/daemon/dispatcher.dart +++ b/lib/src/daemon/dispatcher.dart @@ -9,6 +9,7 @@ class DaemonDispatcher { DaemonDispatcher() { register('ping', _ping); register('version', _version); + register('capabilities', _capabilities); } final Map _handlers = {}; @@ -29,13 +30,17 @@ class DaemonDispatcher { } } - /// Remove all registered handlers except ping/version. + /// Built-in commands registered in the constructor; survive [clear] and + /// don't count toward [isEmpty]. + static const _builtins = {'ping', 'version', 'capabilities'}; + + /// Remove all registered handlers except the built-ins. void clear() { - _handlers.removeWhere((k, _) => k != 'ping' && k != 'version'); - _schemas.removeWhere((k, _) => k != 'ping' && k != 'version'); + _handlers.removeWhere((k, _) => !_builtins.contains(k)); + _schemas.removeWhere((k, _) => !_builtins.contains(k)); } - bool get isEmpty => _handlers.length <= 2; // only ping + version + bool get isEmpty => _handlers.length <= _builtins.length; Future dispatch(IpcRequest req) async { final h = _handlers[req.cmd]; @@ -72,4 +77,36 @@ class DaemonDispatcher { id: req.id, data: {'version': clideVersion}, ); + + /// Reflects the live command registry so the surface is discoverable, not + /// just present (T-248). Every registered verb is listed — split into + /// subsystem + verb — with its argument schema (positional order + per-arg + /// type/required/constraints) where one is declared. Sourced from the + /// registry, so it never drifts from what actually dispatches. + Future _capabilities(IpcRequest req) async { + final names = _handlers.keys.toList()..sort(); + final commands = {}; + for (final cmd in names) { + final dot = cmd.indexOf('.'); + final schema = _schemas[cmd]; + commands[cmd] = { + 'subsystem': dot >= 0 ? cmd.substring(0, dot) : '', + 'verb': dot >= 0 ? cmd.substring(dot + 1) : cmd, + if (schema != null) 'positional': schema.positional, + if (schema != null) 'args': {for (final e in schema.args.entries) e.key: _argSpecJson(e.value)}, + }; + } + return IpcResponse.ok(id: req.id, data: {'version': clideVersion, 'commands': commands}); + } + + static Map _argSpecJson(ArgSpec s) => { + 'type': s.type.name, + if (s.required) 'required': true, + if (s.allowed != null) 'allowed': (s.allowed!.toList()..sort()), + if (s.pattern != null) 'pattern': s.pattern!.pattern, + if (s.min != null) 'min': s.min, + if (s.max != null) 'max': s.max, + if (s.maxItems != null) 'maxItems': s.maxItems, + if (s.rejectLeadingDash) 'rejectLeadingDash': true, + }; } diff --git a/test/daemon/dispatcher_test.dart b/test/daemon/dispatcher_test.dart index dee05977..b3ff9432 100644 --- a/test/daemon/dispatcher_test.dart +++ b/test/daemon/dispatcher_test.dart @@ -2,6 +2,7 @@ library; import 'package:clide/clide.dart'; +import 'package:clide/src/ipc/command_schema.dart'; import 'package:test/test.dart'; IpcRequest _req(String cmd, {String id = '1', Map args = const {}}) { @@ -51,7 +52,44 @@ void main() { expect(d.isEmpty, isFalse); }); - test('clear removes user handlers but keeps ping + version', () async { + test('capabilities reflects the live registry with schemas (T-248)', () async { + final d = DaemonDispatcher(); + d.register('echo', (req) async => IpcResponse.ok(id: req.id, data: const {})); + d.register( + 'pane.resize', + (req) async => IpcResponse.ok(id: req.id, data: const {}), + schema: const CommandSchema( + positional: ['id', 'cols'], + args: { + 'id': ArgSpec(required: true), + 'cols': ArgSpec(type: ArgType.number, min: 1), + }, + ), + ); + + final r = await d.dispatch(_req('capabilities')); + expect(r.ok, isTrue); + final commands = r.data['commands'] as Map; + // Built-ins + the two just registered are all discoverable. + expect(commands.keys, containsAll(['ping', 'version', 'capabilities', 'echo', 'pane.resize'])); + + // Subsystem/verb split. + final resize = commands['pane.resize'] as Map; + expect(resize['subsystem'], 'pane'); + expect(resize['verb'], 'resize'); + expect(resize['positional'], ['id', 'cols']); + final args = resize['args'] as Map; + expect((args['id'] as Map)['required'], true); + expect((args['cols'] as Map)['type'], 'number'); + expect((args['cols'] as Map)['min'], 1); + + // A schema-less command carries no positional/args keys. + final echo = commands['echo'] as Map; + expect(echo['subsystem'], ''); + expect(echo.containsKey('args'), isFalse); + }); + + test('clear removes user handlers but keeps the built-ins', () async { final d = DaemonDispatcher(); d.register('extra', (req) async => IpcResponse.ok(id: req.id, data: const {})); expect(d.isEmpty, isFalse);