diff --git a/bin/clide.dart b/bin/clide.dart index ab87bfa3..57969921 100644 --- a/bin/clide.dart +++ b/bin/clide.dart @@ -20,8 +20,10 @@ import 'package:clide/src/daemon/editor_commands.dart'; import 'package:clide/src/daemon/files_commands.dart'; import 'package:clide/src/daemon/git_commands.dart'; import 'package:clide/src/daemon/pane_commands.dart'; +import 'package:clide/src/daemon/pql_commands.dart'; import 'package:clide/src/editor/registry.dart' show EditorRegistry; import 'package:clide/src/panes/registry.dart'; +import 'package:clide/src/pql/client.dart'; Future main(List argv) async { if (argv.isEmpty) { @@ -152,6 +154,9 @@ Future _runDaemon(List args) async { registerGitCommands(dispatcher, files.root, events); + final pql = PqlClient(workDir: files.root); + registerPqlCommands(dispatcher, pql); + final stopping = Completer(); void shutdown(ProcessSignal sig) { if (!stopping.isCompleted) { diff --git a/lib/clide.dart b/lib/clide.dart index 229bcfdd..b374b78c 100644 --- a/lib/clide.dart +++ b/lib/clide.dart @@ -24,6 +24,7 @@ export 'src/git/diff.dart' show GitDiff, GitHunk, DiffLine, DiffLineKind; export 'src/git/operations.dart' show GitLogEntry, GitException; export 'src/git/status.dart' show GitStatus, GitFileStatus, GitFileState, GitConflictType; +export 'src/pql/client.dart' show PqlClient, PqlException; export 'src/ipc/envelope.dart'; export 'src/ipc/paths.dart'; export 'src/ipc/schema_v1.dart'; diff --git a/lib/src/daemon/pql_commands.dart b/lib/src/daemon/pql_commands.dart new file mode 100644 index 00000000..6980ad95 --- /dev/null +++ b/lib/src/daemon/pql_commands.dart @@ -0,0 +1,227 @@ +/// Registers `pql.*` command handlers on the daemon dispatcher. +/// +/// Thin pass-through to [PqlClient] — the daemon is a JSON relay +/// between the IPC socket and the pql CLI. Per D-003, clide wraps +/// pql; it never re-implements. +library; + +import '../ipc/envelope.dart'; +import '../ipc/schema_v1.dart'; +import '../pql/client.dart'; +import 'dispatcher.dart'; + +void registerPqlCommands(DaemonDispatcher d, PqlClient pql) { + d.register('pql.files', (req) async { + try { + final glob = req.args['glob'] as String?; + final limit = (req.args['limit'] as num?)?.toInt(); + final files = await pql.files(glob: glob, limit: limit); + return IpcResponse.ok(id: req.id, data: {'files': files}); + } on PqlException catch (e) { + return _pqlError(req.id, e); + } + }); + + d.register('pql.meta', (req) async { + final path = req.args['path'] as String?; + if (path == null || path.isEmpty) { + return _userError(req.id, 'pql.meta requires a path'); + } + try { + final meta = await pql.meta(path); + return IpcResponse.ok(id: req.id, data: meta); + } on PqlException catch (e) { + return _pqlError(req.id, e); + } + }); + + d.register('pql.backlinks', (req) async { + final path = req.args['path'] as String?; + if (path == null || path.isEmpty) { + return _userError(req.id, 'pql.backlinks requires a path'); + } + try { + final links = await pql.backlinks(path); + return IpcResponse.ok(id: req.id, data: {'links': links}); + } on PqlException catch (e) { + return _pqlError(req.id, e); + } + }); + + d.register('pql.outlinks', (req) async { + final path = req.args['path'] as String?; + if (path == null || path.isEmpty) { + return _userError(req.id, 'pql.outlinks requires a path'); + } + try { + final links = await pql.outlinks(path); + return IpcResponse.ok(id: req.id, data: {'links': links}); + } on PqlException catch (e) { + return _pqlError(req.id, e); + } + }); + + d.register('pql.tags', (req) async { + try { + final limit = (req.args['limit'] as num?)?.toInt(); + final tags = await pql.tags(limit: limit); + return IpcResponse.ok(id: req.id, data: {'tags': tags}); + } on PqlException catch (e) { + return _pqlError(req.id, e); + } + }); + + d.register('pql.schema', (req) async { + try { + final schema = await pql.schema(); + return IpcResponse.ok(id: req.id, data: {'schema': schema}); + } on PqlException catch (e) { + return _pqlError(req.id, e); + } + }); + + d.register('pql.query', (req) async { + final dsl = req.args['query'] as String?; + if (dsl == null || dsl.isEmpty) { + return _userError(req.id, 'pql.query requires a query string'); + } + try { + final limit = (req.args['limit'] as num?)?.toInt(); + final results = await pql.query(dsl, limit: limit); + return IpcResponse.ok(id: req.id, data: {'results': results}); + } on PqlException catch (e) { + return _pqlError(req.id, e); + } + }); + + d.register('pql.doctor', (req) async { + try { + final report = await pql.doctor(); + return IpcResponse.ok(id: req.id, data: report); + } on PqlException catch (e) { + return _pqlError(req.id, e); + } + }); + + d.register('pql.decisions.sync', (req) async { + try { + final result = await pql.decisionSync(); + return IpcResponse.ok(id: req.id, data: result); + } on PqlException catch (e) { + return _pqlError(req.id, e); + } + }); + + d.register('pql.decisions.list', (req) async { + try { + final results = await pql.decisionList( + type: req.args['type'] as String?, + domain: req.args['domain'] as String?, + status: req.args['status'] as String?, + ); + return IpcResponse.ok(id: req.id, data: {'decisions': results}); + } on PqlException catch (e) { + return _pqlError(req.id, e); + } + }); + + d.register('pql.decisions.show', (req) async { + final id = req.args['id'] as String?; + if (id == null || id.isEmpty) { + return _userError(req.id, 'pql.decisions.show requires an id'); + } + try { + final result = await pql.decisionShow( + id, + withRefs: req.args['withRefs'] as bool? ?? false, + withTickets: req.args['withTickets'] as bool? ?? false, + ); + return IpcResponse.ok(id: req.id, data: result); + } on PqlException catch (e) { + return _pqlError(req.id, e); + } + }); + + d.register('pql.decisions.coverage', (req) async { + try { + final gaps = await pql.decisionCoverage(); + return IpcResponse.ok(id: req.id, data: {'gaps': gaps}); + } on PqlException catch (e) { + return _pqlError(req.id, e); + } + }); + + d.register('pql.tickets.list', (req) async { + try { + final results = await pql.ticketList( + status: req.args['status'] as String?, + team: req.args['team'] as String?, + assigned: req.args['assigned'] as String?, + decision: req.args['decision'] as String?, + ); + return IpcResponse.ok(id: req.id, data: {'tickets': results}); + } on PqlException catch (e) { + return _pqlError(req.id, e); + } + }); + + d.register('pql.tickets.show', (req) async { + final id = req.args['id'] as String?; + if (id == null || id.isEmpty) { + return _userError(req.id, 'pql.tickets.show requires an id'); + } + try { + final result = await pql.ticketShow( + id, + withDecision: req.args['withDecision'] as bool? ?? false, + withBlockers: req.args['withBlockers'] as bool? ?? false, + ); + return IpcResponse.ok(id: req.id, data: result); + } on PqlException catch (e) { + return _pqlError(req.id, e); + } + }); + + d.register('pql.tickets.board', (req) async { + try { + final board = await pql.ticketBoard( + team: req.args['team'] as String?, + ); + return IpcResponse.ok(id: req.id, data: {'columns': board}); + } on PqlException catch (e) { + return _pqlError(req.id, e); + } + }); + + d.register('pql.plan.status', (req) async { + try { + final status = await pql.planStatus(); + return IpcResponse.ok(id: req.id, data: status); + } on PqlException catch (e) { + return _pqlError(req.id, e); + } + }); +} + +IpcResponse _userError(String id, String message) { + return IpcResponse.err( + id: id, + error: IpcError( + code: IpcExitCode.userError, + kind: IpcErrorKind.userError, + message: message, + ), + ); +} + +IpcResponse _pqlError(String id, PqlException e) { + return IpcResponse.err( + id: id, + error: IpcError( + code: IpcExitCode.toolError, + kind: IpcErrorKind.toolError, + message: e.message, + hint: e.stderr.isNotEmpty ? e.stderr : null, + ), + ); +} diff --git a/lib/src/pql/client.dart b/lib/src/pql/client.dart new file mode 100644 index 00000000..1036fc45 --- /dev/null +++ b/lib/src/pql/client.dart @@ -0,0 +1,172 @@ +/// pql CLI wrapper — shells out per D-003 (wrap, don't duplicate). +/// +/// Every function runs `pql ` as a subprocess, parses +/// the JSON stdout, and returns typed Dart maps. Errors surface as +/// [PqlException] with the stderr diagnostics attached. +library; + +import 'dart:convert'; +import 'dart:io'; + +class PqlException implements Exception { + const PqlException(this.message, {this.exitCode = 1, this.stderr = ''}); + final String message; + final int exitCode; + final String stderr; + + @override + String toString() => 'PqlException($exitCode): $message'; +} + +class PqlClient { + PqlClient({required this.workDir, this.pqlBinary = 'pql'}); + + final Directory workDir; + final String pqlBinary; + + Future>> files({String? glob, int? limit}) async { + final args = ['files']; + if (glob != null) args.add(glob); + if (limit != null) args.addAll(['--limit', '$limit']); + return _runList(args); + } + + Future> meta(String path) async { + return _runObject(['meta', path]); + } + + Future>> backlinks(String path) async { + return _runList(['backlinks', path]); + } + + Future>> outlinks(String path) async { + return _runList(['outlinks', path]); + } + + Future>> tags({int? limit}) async { + final args = ['tags']; + if (limit != null) args.addAll(['--limit', '$limit']); + return _runList(args); + } + + Future>> schema() async { + return _runList(['schema']); + } + + Future>> query(String dsl, {int? limit}) async { + final args = ['query', dsl]; + if (limit != null) args.addAll(['--limit', '$limit']); + return _runList(args); + } + + Future> doctor() async { + return _runObject(['doctor']); + } + + Future> decisionSync() async { + return _runObject(['decisions', 'sync']); + } + + Future decisionValidate() async { + return _runObject(['decisions', 'validate']); + } + + Future>> decisionList({ + String? type, + String? domain, + String? status, + }) async { + final args = ['decisions', 'list']; + if (type != null) args.addAll(['--type', type]); + if (domain != null) args.addAll(['--domain', domain]); + if (status != null) args.addAll(['--status', status]); + return _runList(args); + } + + Future> decisionShow( + String id, { + bool withRefs = false, + bool withTickets = false, + }) async { + final args = ['decisions', 'show', id]; + if (withRefs) args.add('--with-refs'); + if (withTickets) args.add('--with-tickets'); + return _runObject(args); + } + + Future>> decisionCoverage() async { + return _runList(['decisions', 'coverage']); + } + + Future>> ticketList({ + String? status, + String? team, + String? assigned, + String? decision, + }) async { + final args = ['ticket', 'list']; + if (status != null) args.addAll(['--status', status]); + if (team != null) args.addAll(['--team', team]); + if (assigned != null) args.addAll(['--assigned', assigned]); + if (decision != null) args.addAll(['--decision', decision]); + return _runList(args); + } + + Future> ticketShow( + String id, { + bool withDecision = false, + bool withBlockers = false, + }) async { + final args = ['ticket', 'show', id]; + if (withDecision) args.add('--with-decision'); + if (withBlockers) args.add('--with-blockers'); + return _runObject(args); + } + + Future>> ticketBoard({String? team}) async { + final args = ['ticket', 'board']; + if (team != null) args.addAll(['--team', team]); + return _runList(args); + } + + Future> planStatus() async { + return _runObject(['plan', 'status']); + } + + // ------------------------------------------------------------------- + + Future>> _runList(List args) async { + final result = await _run(args); + if (result == null) return const []; + if (result is List) { + return [for (final e in result) (e as Map).cast()]; + } + return const []; + } + + Future> _runObject(List args) async { + final result = await _run(args); + if (result is Map) return result.cast(); + return const {}; + } + + Future _run(List args) async { + final r = await Process.run( + pqlBinary, + args, + workingDirectory: workDir.path, + ); + final stderr = (r.stderr as String).trim(); + // Exit 2 = zero matches — valid empty result, not an error. + if (r.exitCode != 0 && r.exitCode != 2) { + throw PqlException( + 'pql ${args.first} failed', + exitCode: r.exitCode, + stderr: stderr, + ); + } + final stdout = (r.stdout as String).trim(); + if (stdout.isEmpty) return null; + return jsonDecode(stdout); + } +} diff --git a/test/daemon/pql_commands_test.dart b/test/daemon/pql_commands_test.dart new file mode 100644 index 00000000..cf88f725 --- /dev/null +++ b/test/daemon/pql_commands_test.dart @@ -0,0 +1,129 @@ +import 'dart:io'; + +import 'package:clide/clide.dart'; +import 'package:clide/src/daemon/pql_commands.dart'; +import 'package:clide/src/pql/client.dart'; +import 'package:test/test.dart'; + +void main() { + late DaemonDispatcher dispatcher; + late PqlClient pql; + + setUp(() { + pql = PqlClient(workDir: Directory.current); + dispatcher = DaemonDispatcher(); + registerPqlCommands(dispatcher, pql); + }); + + Future call(String cmd, + [Map args = const {}]) { + return dispatcher.dispatch(IpcRequest(id: '1', cmd: cmd, args: args)); + } + + test('pql.files returns a list of files', () async { + final r = await call('pql.files', {'limit': 3}); + expect(r.ok, isTrue); + final files = r.data['files'] as List; + expect(files, isNotEmpty); + expect(files.length, lessThanOrEqualTo(3)); + final first = (files.first as Map).cast(); + expect(first.containsKey('path'), isTrue); + expect(first.containsKey('name'), isTrue); + }); + + test('pql.meta returns file metadata', () async { + final r = await call('pql.meta', {'path': 'CLAUDE.md'}); + expect(r.ok, isTrue); + expect(r.data['path'], 'CLAUDE.md'); + expect(r.data.containsKey('outlinks'), isTrue); + }); + + test('pql.meta without path returns error', () async { + final r = await call('pql.meta'); + expect(r.ok, isFalse); + expect(r.error!.kind, 'user_error'); + }); + + test('pql.outlinks returns links from a file', () async { + final r = await call('pql.outlinks', {'path': 'CLAUDE.md'}); + expect(r.ok, isTrue); + final links = r.data['links'] as List; + expect(links, isNotEmpty); + }); + + test('pql.schema returns the frontmatter schema', () async { + final r = await call('pql.schema'); + expect(r.ok, isTrue); + expect(r.data.containsKey('schema'), isTrue); + }); + + test('pql.doctor returns diagnostic report', () async { + final r = await call('pql.doctor'); + expect(r.ok, isTrue); + expect(r.data.containsKey('vault'), isTrue); + expect(r.data.containsKey('config'), isTrue); + expect(r.data.containsKey('version'), isTrue); + }); + + test('pql.decisions.sync parses decisions', () async { + final r = await call('pql.decisions.sync'); + expect(r.ok, isTrue); + expect(r.data.containsKey('synced'), isTrue); + expect((r.data['synced'] as num).toInt(), greaterThan(0)); + }); + + test('pql.decisions.list returns confirmed decisions', () async { + await call('pql.decisions.sync'); + final r = await call('pql.decisions.list', {'type': 'confirmed'}); + expect(r.ok, isTrue); + final decisions = r.data['decisions'] as List; + expect(decisions, isNotEmpty); + }); + + test('pql.decisions.show returns a single decision', () async { + await call('pql.decisions.sync'); + final r = await call('pql.decisions.show', {'id': 'D-001'}); + expect(r.ok, isTrue); + expect(r.data['id'], 'D-001'); + expect(r.data['title'], isNotEmpty); + }); + + test('pql.decisions.show without id returns error', () async { + final r = await call('pql.decisions.show'); + expect(r.ok, isFalse); + expect(r.error!.kind, 'user_error'); + }); + + test('pql.decisions.coverage returns gaps', () async { + await call('pql.decisions.sync'); + final r = await call('pql.decisions.coverage'); + expect(r.ok, isTrue); + expect(r.data.containsKey('gaps'), isTrue); + }); + + test('pql.tickets.board returns columns', () async { + final r = await call('pql.tickets.board'); + expect(r.ok, isTrue); + expect(r.data.containsKey('columns'), isTrue); + }); + + test('pql.plan.status returns dashboard', () async { + await call('pql.decisions.sync'); + final r = await call('pql.plan.status'); + expect(r.ok, isTrue); + expect(r.data.containsKey('decisions'), isTrue); + expect(r.data.containsKey('tickets'), isTrue); + }); + + test('pql.query without query returns error', () async { + final r = await call('pql.query'); + expect(r.ok, isFalse); + expect(r.error!.kind, 'user_error'); + }); + + test('pql.backlinks without path returns error', () async { + final r = await call('pql.backlinks'); + expect(r.ok, isFalse); + expect(r.error!.kind, 'user_error'); + }); +}