dew init with module init hooks (DEW-0010)

- DewInitHook interface and DewInitOptions added to packages/core/src/init.dart
- InitCommand: creates .project/dew.yaml from default template, calls hooks
  --path/-p option (default '.'), --[no-]gitkeep flag (default true)
- CommandRegistry gains initHooks list and registerInitHook()
- KanbanInitHook: creates column dirs, archive/, attachments/ under
  .project/kanban/; adds .gitkeep to freshly-created dirs when enabled
- KanbanInitHook registered in kanban.registerCommands()
- CLI wires InitCommand(registry.initHooks) as a top-level command

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Chris Hendrickson 2026-04-23 19:58:53 -04:00
parent 198d65b7e2
commit 31f9ba4726
9 changed files with 176 additions and 1 deletions

View file

@ -11,6 +11,7 @@ Future<void> main(List<String> args) async {
final runner = CommandRunner<void>('dew', 'A project management tool.'); final runner = CommandRunner<void>('dew', 'A project management tool.');
runner.addCommand(InitCommand(commandRegistry.initHooks));
for (final command in commandRegistry.commands) { for (final command in commandRegistry.commands) {
runner.addCommand(command); runner.addCommand(command);
} }

View file

@ -2,3 +2,4 @@ library;
export 'src/config.dart'; export 'src/config.dart';
export 'src/dew_core_base.dart'; export 'src/dew_core_base.dart';
export 'src/init.dart';

View file

@ -1,6 +1,8 @@
import 'package:args/args.dart'; import 'package:args/args.dart';
import 'package:args/command_runner.dart'; import 'package:args/command_runner.dart';
import 'init.dart';
typedef McpToolHandler = Future<String> Function(Map<String, dynamic> args); typedef McpToolHandler = Future<String> Function(Map<String, dynamic> args);
/// A single tool exposed to an MCP client. /// A single tool exposed to an MCP client.
@ -136,13 +138,20 @@ mixin DewToolCommand on DewCommand {
/// [CommandRunner]. /// [CommandRunner].
class CommandRegistry { class CommandRegistry {
final List<DewCommand> _commands = []; final List<DewCommand> _commands = [];
final List<DewInitHook> _initHooks = [];
/// Adds [command] to the registry. /// Adds [command] to the registry.
void register(DewCommand command) => _commands.add(command); void register(DewCommand command) => _commands.add(command);
/// Registers an [DewInitHook] to be called during `dew init`.
void registerInitHook(DewInitHook hook) => _initHooks.add(hook);
/// An unmodifiable view of all registered commands. /// An unmodifiable view of all registered commands.
List<DewCommand> get commands => List.unmodifiable(_commands); List<DewCommand> get commands => List.unmodifiable(_commands);
/// An unmodifiable view of all registered init hooks.
List<DewInitHook> get initHooks => List.unmodifiable(_initHooks);
/// Collects all [McpTool]s from commands that mix in [DewToolCommand], /// Collects all [McpTool]s from commands that mix in [DewToolCommand],
/// recursively including subcommands. /// recursively including subcommands.
List<McpTool> get mcpTools { List<McpTool> get mcpTools {

View file

@ -0,0 +1,118 @@
import 'dart:io';
import 'package:args/command_runner.dart';
import 'package:path/path.dart' as p;
import 'package:yaml/yaml.dart';
import 'config.dart';
/// Options passed to every [DewInitHook] during `dew init`.
class DewInitOptions {
/// Whether to create `.gitkeep` files in newly-created empty directories.
final bool gitkeep;
const DewInitOptions({this.gitkeep = true});
}
/// Creates the initial `.project/` scaffold for `dew init`.
///
/// Each module (kanban, mcp, etc.) registers a hook so it can create its own
/// subdirectories and config alongside the core scaffold.
abstract interface class DewInitHook {
/// Called after `dew.yaml` is written. [projectRoot] is the resolved absolute
/// path of the project being initialised; [config] is the loaded config;
/// [options] carries flags like [DewInitOptions.gitkeep].
Future<void> onInit(
String projectRoot,
DewConfig config,
DewInitOptions options,
);
}
const _defaultDewYaml = '''
dew:
mcp:
host: "localhost"
port: 8080
kanban:
prefix: "PROJ"
ticket_types:
- id: "epic"
name: "Epic"
- id: "story"
name: "Story"
- id: "task"
name: "Task"
- id: "bug"
name: "Bug"
- id: "spike"
name: "Spike"
columns:
- id: "backlog"
name: "Backlog"
color: "blue"
- id: "doing"
name: "Doing"
color: "yellow"
- id: "done"
name: "Done"
color: "green"
''';
class InitCommand extends Command<void> {
final List<DewInitHook> _hooks;
InitCommand(this._hooks) {
argParser
..addOption(
'path',
abbr: 'p',
help: 'Path to the project root to initialise.',
defaultsTo: '.',
)
..addFlag(
'gitkeep',
help: 'Add .gitkeep files to newly-created empty directories.',
defaultsTo: true,
);
}
@override
final String name = 'init';
@override
final String description =
'Initialise a Dew project at the given path, creating '
'.project/dew.yaml and scaffolding module directories.';
@override
Future<void> run() async {
final rawPath = argResults!['path'] as String;
final gitkeep = argResults!['gitkeep'] as bool;
final projectRoot = p.canonicalize(rawPath);
final options = DewInitOptions(gitkeep: gitkeep);
final projectDir = Directory(p.join(projectRoot, '.project'));
final configFile = File(p.join(projectDir.path, 'dew.yaml'));
await projectDir.create(recursive: true);
if (await configFile.exists()) {
print(' found .project/dew.yaml (already exists, skipping)');
} else {
await configFile.writeAsString(_defaultDewYaml.trimLeft());
print(' created .project/dew.yaml');
}
final config = DewConfig.fromYaml(
loadYaml(await configFile.readAsString()) as YamlMap,
);
for (final hook in _hooks) {
await hook.onInit(projectRoot, config, options);
}
print('\nProject initialised at $projectRoot');
}
}

View file

@ -2,13 +2,16 @@ library;
export 'src/dew_kanban_base.dart'; export 'src/dew_kanban_base.dart';
export 'src/kanban_config.dart'; export 'src/kanban_config.dart';
export 'src/kanban_init_hook.dart';
export 'src/ticket.dart'; export 'src/ticket.dart';
export 'src/ticket_store.dart'; export 'src/ticket_store.dart';
import 'package:dew_core/dew_core.dart'; import 'package:dew_core/dew_core.dart';
import 'package:dew_kanban/src/dew_kanban_base.dart'; import 'package:dew_kanban/src/dew_kanban_base.dart';
import 'package:dew_kanban/src/kanban_init_hook.dart';
/// Registers all Kanban commands into [registry]. /// Registers all Kanban commands and init hooks into [registry].
void registerCommands(CommandRegistry registry) { void registerCommands(CommandRegistry registry) {
registry.register(KanbanCommand()); registry.register(KanbanCommand());
registry.registerInitHook(KanbanInitHook());
} }

View file

@ -0,0 +1,43 @@
import 'dart:io';
import 'package:dew_core/dew_core.dart';
import 'package:path/path.dart' as p;
import 'kanban_config.dart';
class KanbanInitHook implements DewInitHook {
@override
Future<void> onInit(
String projectRoot,
DewConfig config,
DewInitOptions options,
) async {
final kanbanConfig = config.kanban;
final kanbanRoot = p.join(projectRoot, '.project', 'kanban');
// Column directories.
for (final column in kanbanConfig.columns) {
await _createDir(p.join(kanbanRoot, column.id), options.gitkeep);
}
// Archive and attachments directories.
await _createDir(p.join(kanbanRoot, 'archive'), options.gitkeep);
await _createDir(p.join(kanbanRoot, 'attachments'), options.gitkeep);
}
Future<void> _createDir(String path, bool gitkeep) async {
final dir = Directory(path);
final existed = await dir.exists();
await dir.create(recursive: true);
final rel = '.project/kanban/${p.basename(path)}';
if (existed) {
print(' found $rel/');
} else {
print(' created $rel/');
if (gitkeep) {
await File(p.join(path, '.gitkeep')).writeAsString('');
print(' created $rel/.gitkeep');
}
}
}
}