import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:args/command_runner.dart'; import 'package:dart_mcp/stdio.dart'; import 'package:halkyon_learn_agent/src/agent_home.dart'; import 'package:halkyon_learn_agent/src/agent_server.dart'; import 'package:halkyon_learn_agent/src/open_agent.dart'; /// Halkyon Learn's agent (Halkyon's ADR 0044, D50, D51): `sign-in` keeps /// the token its owner made, `sync` syncs once, and `mcp` serves its tools /// over stdio to Claude Code or another MCP client. Everything it says /// goes to stderr, as stdout is the protocol's. Future main(List arguments) async { final runner = CommandRunner( 'halkyon-agent', "Halkyon Learn's agent: reads the studysets shared with it and " 'suggests changes to them.', ) ..argParser.addOption( 'instance', help: 'The instance it is an agent on, e.g. https://halkyon.li.', mandatory: true, ) ..addCommand(_SignIn()) ..addCommand(_Sync()) ..addCommand(_Mcp()); try { await runZoned( () => runner.run(arguments), zoneSpecification: ZoneSpecification( print: (self, parent, zone, line) => stderr.writeln(line), ), ); } on UsageException catch (usage) { stderr.writeln(usage); exitCode = 64; } on AgentHomeRefused catch (refused) { stderr.writeln(refused.message); exitCode = 1; } on Exception catch (failure) { // The instance out of reach, the token refused, a sync failing: said // in one line, the exit code telling a script. stderr.writeln('halkyon-agent: $failure'); exitCode = 1; } } /// The instance the command runs on, and its home. Future<(Uri, AgentHome)> _homeOf(Command command) async { final instance = Uri.parse(command.globalResults!.option('instance')!); return (instance, await AgentHome.open(AgentHome.defaultRoot(), instance)); } /// Keeps the token read from stdin, then signs in with it, making and /// setting the agent's keys and syncing once. final class _SignIn() extends Command { @override String get name => 'sign-in'; @override String get description => 'Keeps the token read from stdin (never an argument, which others on ' 'this machine may see), signs in and syncs.'; @override Future run() async { final (instance, home) = await _homeOf(this); final token = (await stdin.transform(utf8.decoder).join()).trim(); if (token.isEmpty) { throw UsageException('No token on stdin.', usage); } await home.keepToken(token); final agent = await openAgent(home, instance); try { await agent.sync.syncNow(); final shared = await agent.library.sharedStudysets(); stderr.writeln( 'Signed in on ${instance.host}; ${shared.length} studysets shared.', ); } finally { await agent.close(); } } } /// Syncs once. final class _Sync() extends Command { @override String get name => 'sync'; @override String get description => 'Syncs the studysets shared with it, once.'; @override Future run() async { final (instance, home) = await _homeOf(this); final agent = await openAgent(home, instance); try { await agent.sync.syncNow(); } finally { await agent.close(); } } } /// Serves its tools over stdio until the client goes. final class _Mcp() extends Command { @override String get name => 'mcp'; @override String get description => 'Serves its tools to an MCP client over stdio, e.g. ' '`claude mcp add halkyon -- halkyon-agent --instance URL mcp`.'; @override Future run() async { final (instance, home) = await _homeOf(this); final agent = await openAgent(home, instance); final server = AgentServer( stdioChannel(input: stdin, output: stdout), agent.library, sync: agent.sync.syncNow, ); try { await server.done; } finally { await agent.close(); } } }