make the clide command surface self-describing
Parity guarantees a verb exists for every UI action, but a verb is unreachable if nothing advertises it. Add `clide capabilities` — it reflects the live dispatcher registry to JSON (subsystem, verb, arg schema) so the surface is discoverable and can't drift from what dispatches. A thin /clide skill points Claude at it rather than hard-coding a verb list, so new panels become reachable the moment they register. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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 <subsystem> <verb>`
|
||||
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 <subsystem>` 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 <reader> <ref>` — 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.
|
||||
@@ -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;
|
||||
|
||||
@@ -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 <subsystem>` 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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<String> _umbrellaCommands = {'status', 'tail', 'version', 'ping'};
|
||||
const Set<String> _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.
|
||||
|
||||
@@ -9,6 +9,7 @@ class DaemonDispatcher {
|
||||
DaemonDispatcher() {
|
||||
register('ping', _ping);
|
||||
register('version', _version);
|
||||
register('capabilities', _capabilities);
|
||||
}
|
||||
|
||||
final Map<String, CommandHandler> _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<IpcResponse> 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<IpcResponse> _capabilities(IpcRequest req) async {
|
||||
final names = _handlers.keys.toList()..sort();
|
||||
final commands = <String, Object?>{};
|
||||
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<String, Object?> _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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<String, Object?> 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<String, Object?>;
|
||||
// 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<String, Object?>;
|
||||
expect(resize['subsystem'], 'pane');
|
||||
expect(resize['verb'], 'resize');
|
||||
expect(resize['positional'], ['id', 'cols']);
|
||||
final args = resize['args'] as Map<String, Object?>;
|
||||
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<String, Object?>;
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user