Some checks failed
CI / check (push) Failing after 1m56s
Halkyon's ADR 0044, step 6c (D7, D49 to D52). apps/learn/halkyon_learn_agent is one executable, `halkyon-agent --instance URL sign-in | sync | mcp`: - sign-in keeps the token read from stdin, signs in with it, makes the agent's key pair on its first sign-in, keeps it before setting the public halves on the register (agents.setKeys), and syncs; - it opens its data as the app's _openPersonData does, on halkyon_learn_core and halkyon_content: its own SQLite database, the sync's space router, its keys and those of the spaces shared with it, and the repositories of studysets, cards, notes, paths, files and feedback; - mcp serves ten tools over stdio (dart_mcp): list, read and search the studysets shared with it, suggest a change or a new card or note, report, list a studyset's suggestions and reply. Each syncs first and a suggestion again at once; a refusal is the tool's error; - its files live in $XDG_CONFIG_HOME/halkyon-agent/<host>, 0700 and 0600, refused when others may read them (D50); - tool/install.sh builds it with `dart build cli` (libsodium and SQLite bundled) and copies it to its user on the host, the build before kept (D52). The synced gateways of learning paths and files, and the fakes of the core's gateways, move into halkyon_learn_core (its testing.dart), which the app's tests and the agent's share. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
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();
|
|
}
|
|
}
|
|
}
|