diff --git a/.project/kanban/doing/DEW-0001.md b/.project/kanban/done/DEW-0001.md similarity index 100% rename from .project/kanban/doing/DEW-0001.md rename to .project/kanban/done/DEW-0001.md diff --git a/.project/kanban/backlog/DEW-0002.md b/.project/kanban/done/DEW-0002.md similarity index 100% rename from .project/kanban/backlog/DEW-0002.md rename to .project/kanban/done/DEW-0002.md diff --git a/.project/kanban/backlog/DEW-0010.md b/.project/kanban/done/DEW-0010.md similarity index 100% rename from .project/kanban/backlog/DEW-0010.md rename to .project/kanban/done/DEW-0010.md diff --git a/packages/cli/bin/dew.dart b/packages/cli/bin/dew.dart index 2ba7e25..367a57f 100644 --- a/packages/cli/bin/dew.dart +++ b/packages/cli/bin/dew.dart @@ -11,6 +11,7 @@ Future main(List args) async { final runner = CommandRunner('dew', 'A project management tool.'); + runner.addCommand(InitCommand(commandRegistry.initHooks)); for (final command in commandRegistry.commands) { runner.addCommand(command); } diff --git a/packages/core/lib/dew_core.dart b/packages/core/lib/dew_core.dart index c5bb97e..295eb4d 100644 --- a/packages/core/lib/dew_core.dart +++ b/packages/core/lib/dew_core.dart @@ -2,3 +2,4 @@ library; export 'src/config.dart'; export 'src/dew_core_base.dart'; +export 'src/init.dart'; diff --git a/packages/core/lib/src/dew_core_base.dart b/packages/core/lib/src/dew_core_base.dart index 9b4c612..dbf6e02 100644 --- a/packages/core/lib/src/dew_core_base.dart +++ b/packages/core/lib/src/dew_core_base.dart @@ -1,6 +1,8 @@ import 'package:args/args.dart'; import 'package:args/command_runner.dart'; +import 'init.dart'; + typedef McpToolHandler = Future Function(Map args); /// A single tool exposed to an MCP client. @@ -136,13 +138,20 @@ mixin DewToolCommand on DewCommand { /// [CommandRunner]. class CommandRegistry { final List _commands = []; + final List _initHooks = []; /// Adds [command] to the registry. 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. List get commands => List.unmodifiable(_commands); + /// An unmodifiable view of all registered init hooks. + List get initHooks => List.unmodifiable(_initHooks); + /// Collects all [McpTool]s from commands that mix in [DewToolCommand], /// recursively including subcommands. List get mcpTools { diff --git a/packages/core/lib/src/init.dart b/packages/core/lib/src/init.dart new file mode 100644 index 0000000..ac6dc81 --- /dev/null +++ b/packages/core/lib/src/init.dart @@ -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 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 { + final List _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 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'); + } +} diff --git a/packages/kanban/lib/dew_kanban.dart b/packages/kanban/lib/dew_kanban.dart index dcc94dc..3eefb9b 100644 --- a/packages/kanban/lib/dew_kanban.dart +++ b/packages/kanban/lib/dew_kanban.dart @@ -2,13 +2,16 @@ library; export 'src/dew_kanban_base.dart'; export 'src/kanban_config.dart'; +export 'src/kanban_init_hook.dart'; export 'src/ticket.dart'; export 'src/ticket_store.dart'; import 'package:dew_core/dew_core.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) { registry.register(KanbanCommand()); + registry.registerInitHook(KanbanInitHook()); } diff --git a/packages/kanban/lib/src/kanban_init_hook.dart b/packages/kanban/lib/src/kanban_init_hook.dart new file mode 100644 index 0000000..4d8f065 --- /dev/null +++ b/packages/kanban/lib/src/kanban_init_hook.dart @@ -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 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 _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'); + } + } + } +}