134 lines
4 KiB
Dart
134 lines
4 KiB
Dart
|
|
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<void> main(List<String> arguments) async {
|
||
|
|
final runner =
|
||
|
|
CommandRunner<void>(
|
||
|
|
'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<void> 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<void> {
|
||
|
|
@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<void> 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<void> {
|
||
|
|
@override
|
||
|
|
String get name => 'sync';
|
||
|
|
|
||
|
|
@override
|
||
|
|
String get description => 'Syncs the studysets shared with it, once.';
|
||
|
|
|
||
|
|
@override
|
||
|
|
Future<void> 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<void> {
|
||
|
|
@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<void> 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();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|