diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index cb79c106..2af9aef5 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -7262,3 +7262,5 @@ Considerations: 4. The text is short and does not crowd out clideContextNote.', NULL, '2026-06-28 06:15:53', '2026-06-28 06:15:53.132', '2026-06-28 06:15:53.132', NULL, 'd5caf6198c0cad2fbd79b01261659d82', 2) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGSQ789MDB00EQAXTXBVJDXM', 'status', 'in_progress', 'done', NULL, '2026-06-28 06:23:06', '2026-06-28 06:23:06.440', '2026-06-28 06:23:06.440', NULL, 'f6b2c96ac6770981215615c3c467d907', 2) ON CONFLICT(hash) DO NOTHING; INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGSQ8B7WDKZ174GJDGWW0GS0', 'status', 'backlog', 'done', NULL, '2026-06-28 06:35:16', '2026-06-28 06:35:16.152', '2026-06-28 06:35:16.152', NULL, '15de4a7ee941ffa6bb5dfa762e6d7ffc', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGSQ8YSZA5YNP1JE8SJHXWBG', 'status', 'backlog', 'done', NULL, '2026-06-28 06:41:47', '2026-06-28 06:41:47.934', '2026-06-28 06:41:47.934', NULL, 'df08fd309db50c322d5ea27267d23f7b', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGPG4WNNH3BWDRVZXYQTW7Z0', 'status', 'backlog', 'done', NULL, '2026-06-28 06:41:47', '2026-06-28 06:41:47.971', '2026-06-28 06:41:47.971', NULL, '8e792d212a7f92e5201296ed01873607', 2) ON CONFLICT(hash) DO NOTHING; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index 54e7d16d..1c9379fc 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -9355,3 +9355,34 @@ Considerations: 4. The text is short and does not crowd out clideContextNote.', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-28 06:15:53.077', '2026-06-28 06:15:53.131', NULL, '8fca222cfd1b972d7a66ee3ddaaaf879', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGSQ789MDB00EQAXTXBVJDXM', 'task', '06FGPG4WNNH3BWDRVZXYQTW7Z0', 'project.new backend: create dir + git init + minimal scaffold, and the clide project new CLI verb', NULL, 'done', 'medium', NULL, NULL, NULL, '2026-06-28 06:13:51.821', '2026-06-28 06:23:06.439', NULL, 'b94604117d0511c2f7b3e24746d87c46', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGSQ8B7WDKZ174GJDGWW0GS0', 'task', '06FGPG4WNNH3BWDRVZXYQTW7Z0', 'New-project dialog: location + name -> account roadblock -> open; welcome New project action', NULL, 'done', 'medium', NULL, NULL, NULL, '2026-06-28 06:14:00.767', '2026-06-28 06:35:16.152', NULL, 'b54b32a51b09504e2b091c10ae48e9a0', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGSQ8YSZA5YNP1JE8SJHXWBG', 'task', '06FGPG4WNNH3BWDRVZXYQTW7Z0', 'Evolve _NotARepoDialog into ''Initialize this folder as a clide project?'' (reuse the init + account flow)', NULL, 'done', 'medium', NULL, NULL, NULL, '2026-06-28 06:14:05.775', '2026-06-28 06:41:47.934', NULL, '5d85035e5b90e90e18bdeb2849f6a4ec', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FGPG4WNNH3BWDRVZXYQTW7Z0', 'story', NULL, 'New project creation flow (with Claude account roadblock)', 'clide can only OPEN existing git repos today; there is no way to create a new project, and opening a non-repo folder dead-ends at _NotARepoDialog (welcome_view.dart:633). Add a first-class new-project flow. Per the per-repo account design (T-476), the account choice is a real decision only at project birth, so account selection is the flow''s final roadblock step — existing projects never prompt (they keep their binding, or default to the main account; switch later via Settings or the pane badge). + +## Entry points (D-6 parity) +- Welcome view START section: a ''New project...'' action (peer of ''Open folder...''). +- CLI: `clide project new [--dir ] [--account ]`. +- Non-repo open: _NotARepoDialog evolves from a dead end into ''Initialize this folder as a clide project?'' → the same init + account steps. + +## Flow +1. Location + name — pick a parent dir (window.pickDirectory()), enter a name → target //. Validate: non-empty name, target does not already exist. +2. Initialize — create the dir, `git init`, write a minimal non-prescriptive scaffold (a CLAUDE.md stub + .gitignore). No language/templates. +3. Account roadblock — final screen: pick the Claude account this project runs under (registered accounts + Default + ''Add account...''). Selecting binds the new workspace (AccountRegistry.bindWorkspace + accountActionChannel); ''Add account...'' runs add + login (T-485). +4. Open — project.open the new dir so the workspace goes live; the primary Claude pane spawns under the bound account. + +## Reuses (already shipped under T-476) +- Account picker + binding: account_settings_control.dart (ClaudeAccountsListControl / bindWorkspaceAccount), accountActionChannel. +- git init: lib/src/git/ client. +- project open: kernel.project. + +## Acceptance +1. ''New project...'' creates //, git-inits it, and opens it as the active workspace. +2. The final step binds the chosen account (or Default); a fresh claude in the new project runs under that account''s CLAUDE_CONFIG_DIR. +3. Opening an EXISTING project shows no account prompt. +4. Opening a non-repo folder offers to initialize it (no dead end). +5. `clide project new` mirrors the UI (D-6); every UI action has a CLI counterpart. +6. Tests cover the dialog flow, git-init, the account-bind step, and the non-repo → init path. + +## Out of scope +- Language/framework templates (git init + minimal files only). +- Remote creation (GitHub repo create) — local init only. +- Re-homing existing projects between accounts beyond the existing Settings/badge switch.', 'done', 'medium', NULL, NULL, NULL, '2026-06-27 22:43:31.629', '2026-06-28 06:41:47.971', NULL, '01a340c5205eae82e1b2bb7efb3be179', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash); diff --git a/CHANGELOG.md b/CHANGELOG.md index 29a1ade2..9d39d490 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,9 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit. opens a project, then prompts for the Claude account to bind it to — the per-repo account roadblock fires only for freshly-created projects. (T-488, story T-486) +- **Initialize a non-repo folder.** Opening a folder that isn't a git repo now + offers to initialize it as a clide project (`git init` + scaffold + the + account roadblock) instead of dead-ending. Also `clide project init`. (T-489) - **`clide project new [--dir ]`.** Create a new clide project — a fresh dir, `git init`, and a minimal scaffold. `--dir` defaults to the current workspace's parent. (T-487, story T-486) diff --git a/assets/i18n/en_us/builtin.welcome.json b/assets/i18n/en_us/builtin.welcome.json index d7dd48c9..95533e5c 100644 --- a/assets/i18n/en_us/builtin.welcome.json +++ b/assets/i18n/en_us/builtin.welcome.json @@ -35,6 +35,8 @@ "button.opening": { "translation": "Opening…" }, "button.ok": { "translation": "OK" }, "dialog.notRepo.title": { "translation": "No git repo found" }, - "dialog.notRepo.body": { "translation": "A clide project root requires a git repository." }, + "dialog.notRepo.body": { "translation": "A clide project needs a git repository. Initialize this folder as one?" }, + "dialog.notRepo.initialize": { "translation": "Initialize project" }, + "button.initializing": { "translation": "Initializing…" }, "command.workspace.open-project": { "translation": "Workspace: Open project…" } } diff --git a/assets/i18n/nl_nl/builtin.welcome.json b/assets/i18n/nl_nl/builtin.welcome.json index b8df023f..4ec1c943 100644 --- a/assets/i18n/nl_nl/builtin.welcome.json +++ b/assets/i18n/nl_nl/builtin.welcome.json @@ -35,6 +35,8 @@ "button.opening": { "translation": "Openen…" }, "button.ok": { "translation": "OK" }, "dialog.notRepo.title": { "translation": "Geen git-repo gevonden" }, - "dialog.notRepo.body": { "translation": "Een clide-projecthoofdmap vereist een git-repository." }, + "dialog.notRepo.body": { "translation": "Een clide-project heeft een git-repository nodig. Deze map als project initialiseren?" }, + "dialog.notRepo.initialize": { "translation": "Project initialiseren" }, + "button.initializing": { "translation": "Initialiseren…" }, "command.workspace.open-project": { "translation": "Werkruimte: Project openen…" } } diff --git a/lib/builtin/welcome/src/welcome_view.dart b/lib/builtin/welcome/src/welcome_view.dart index 49b8b6fc..9b3a5a21 100644 --- a/lib/builtin/welcome/src/welcome_view.dart +++ b/lib/builtin/welcome/src/welcome_view.dart @@ -221,7 +221,7 @@ class _StartColumn extends StatelessWidget { if (ok) { kernel.panels.activateTab(Slots.workspace, 'claude.primary'); } else { - kernel.dialog.show((ctx, dismiss) => _NotARepoDialog(path: picked, onDismiss: () => dismiss())); + kernel.dialog.show((ctx, dismiss) => _NotARepoDialog(kernel: kernel, path: picked, onDismiss: () => dismiss())); } } return; @@ -641,16 +641,56 @@ class _OpenProjectDialogState extends State<_OpenProjectDialog> { } } -class _NotARepoDialog extends StatelessWidget { - const _NotARepoDialog({required this.path, required this.onDismiss}); +/// Shown when the opened folder isn't a git repo. Rather than a dead end, it +/// offers to initialize the folder as a clide project (T-489): `project.init`, +/// open, and announce on [projectCreatedChannel] so the account roadblock fires +/// — the same path a brand-new project takes. +class _NotARepoDialog extends StatefulWidget { + const _NotARepoDialog({required this.kernel, required this.path, required this.onDismiss}); + final KernelServices kernel; final String path; final VoidCallback onDismiss; + @override + State<_NotARepoDialog> createState() => _NotARepoDialogState(); +} + +class _NotARepoDialogState extends State<_NotARepoDialog> { + bool _loading = false; + String? _error; + + Future _initialize() async { + setState(() { + _loading = true; + _error = null; + }); + final r = await widget.kernel.ipc.request( + 'project.init', + args: { + 'positional': [], + 'flags': {'dir': widget.path}, + }, + ); + if (!mounted) return; + if (!r.ok) { + return setState(() { + _loading = false; + _error = r.error?.message ?? 'Could not initialize this folder.'; + }); + } + final opened = await widget.kernel.project.open(widget.path); + if (opened) widget.kernel.panels.activateTab(Slots.workspace, 'claude.primary'); + widget.kernel.messages.publish('welcome', projectCreatedChannel, {'dir': widget.path}); + widget.onDismiss(); + } + + String _t(String key, String fallback) => ClideSettings.i18n.string(context, key, namespace: 'builtin.welcome', placeholder: fallback); + @override Widget build(BuildContext context) { final tokens = ClideSettings.theme.of(context).surface; return Container( - width: 420, + width: 460, padding: const EdgeInsets.all(20), decoration: BoxDecoration( color: tokens.modalSurfaceBackground, @@ -661,31 +701,21 @@ class _NotARepoDialog extends StatelessWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - ClideText( - ClideSettings.i18n.string(context, 'dialog.notRepo.title', namespace: 'builtin.welcome', placeholder: 'No git repo found'), - fontSize: clideFontDialogTitle, - fontWeight: FontWeight.w600, - ), + ClideText(_t('dialog.notRepo.title', 'No git repo found'), fontSize: clideFontDialogTitle, fontWeight: FontWeight.w600), const SizedBox(height: 8), - ClideText(path, muted: true, fontSize: clideFontMeta), + ClideText(widget.path, muted: true, fontSize: clideFontMeta), const SizedBox(height: 8), - ClideText( - ClideSettings.i18n.string( - context, - 'dialog.notRepo.body', - namespace: 'builtin.welcome', - placeholder: 'A clide project root requires a git repository.', - ), - muted: true, - fontSize: clideFontMeta, - ), + ClideText(_t('dialog.notRepo.body', 'A clide project needs a git repository. Initialize this folder as one?'), muted: true, fontSize: clideFontMeta), + if (_error != null) ...[const SizedBox(height: 8), ClideText(_error!, color: tokens.statusError, fontSize: clideFontSmall)], const SizedBox(height: 16), Row( mainAxisAlignment: MainAxisAlignment.end, children: [ + ClideButton(label: _t('button.cancel', 'Cancel'), onPressed: widget.onDismiss), + const SizedBox(width: 8), ClideButton( - label: ClideSettings.i18n.string(context, 'button.ok', namespace: 'builtin.welcome', placeholder: 'OK'), - onPressed: () => onDismiss(), + label: _loading ? _t('button.initializing', 'Initializing…') : _t('dialog.notRepo.initialize', 'Initialize project'), + onPressed: _loading ? null : _initialize, ), ], ), diff --git a/lib/main.dart b/lib/main.dart index 2461be64..095fddfd 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -340,6 +340,7 @@ Future main() async { dispatcher, gitInit: (dir) => GitClient(toolchain: tc, workDir: Directory(dir)).init(), defaultParent: () => workRoot.parent.path, + defaultInitPath: () => workRoot.path, ); final pql = PqlClient(workDir: workRoot, toolchain: tc); registerPqlCommands(dispatcher, pql); diff --git a/lib/src/daemon/project_commands.dart b/lib/src/daemon/project_commands.dart index 42d2966b..f50cebc3 100644 --- a/lib/src/daemon/project_commands.dart +++ b/lib/src/daemon/project_commands.dart @@ -66,11 +66,27 @@ Future createNewProject({required String parent, required Stri return NewProjectResult.ok(target); } +/// `git init` an EXISTING folder that isn't a repo yet (T-489) — the "initialize +/// this folder as a clide project" path. Unlike [createNewProject] it never +/// creates the dir and never clobbers existing files; the scaffold is only +/// written where absent. +Future initExistingProject({required String path, required ProjectGitInit gitInit}) async { + final trimmed = _stripTrailingSep(path.trim()); + if (trimmed.isEmpty) return NewProjectResult.err('a directory is required'); + if (!Directory(trimmed).existsSync()) return NewProjectResult.err('directory does not exist: $trimmed'); + await gitInit(trimmed); + _writeScaffold(trimmed, trimmed.split('/').where((s) => s.isNotEmpty).lastOrNull ?? 'project'); + return NewProjectResult.ok(trimmed); +} + void _writeScaffold(String dir, String name) { // Minimal + non-prescriptive: keep clide's own state out of git, and orient - // Claude with an empty CLAUDE.md stub. No language/framework templates. - File('$dir/.gitignore').writeAsStringSync('# clide\n.clide/\n'); - File('$dir/CLAUDE.md').writeAsStringSync('# $name\n\nGuidance for Claude Code in this project.\n'); + // Claude with an empty CLAUDE.md stub. No language/framework templates. Never + // clobbers — safe to run over an existing folder (T-489). + final gitignore = File('$dir/.gitignore'); + if (!gitignore.existsSync()) gitignore.writeAsStringSync('# clide\n.clide/\n'); + final claudeMd = File('$dir/CLAUDE.md'); + if (!claudeMd.existsSync()) claudeMd.writeAsStringSync('# $name\n\nGuidance for Claude Code in this project.\n'); } String _stripTrailingSep(String p) { @@ -81,10 +97,12 @@ String _stripTrailingSep(String p) { return s; } -/// Register `project.new`. [gitInit] runs git init; [defaultParent] supplies the -/// parent dir when `--dir` is omitted (main.dart passes the current workspace's -/// parent, so a new project lands beside the current one). -void registerProjectCommands(DaemonDispatcher d, {required ProjectGitInit gitInit, String? Function()? defaultParent}) { +/// Register `project.new` + `project.init`. [gitInit] runs git init; +/// [defaultParent] supplies the new-project parent when `--dir` is omitted (the +/// current workspace's parent); [defaultInitPath] supplies the init target when +/// `--dir` is omitted (the current workspace, so `clide project init` inits the +/// folder you're in). +void registerProjectCommands(DaemonDispatcher d, {required ProjectGitInit gitInit, String? Function()? defaultParent, String? Function()? defaultInitPath}) { d.register('project.new', (req) async { final name = (req.args['name'] as String?)?.trim(); if (name == null || name.isEmpty) return _err(req.id, 'project new requires a '); @@ -96,6 +114,14 @@ void registerProjectCommands(DaemonDispatcher d, {required ProjectGitInit gitIni if (!result.ok) return _err(req.id, result.error!); return IpcResponse.ok(id: req.id, data: {'path': result.path, 'name': name}); }, schema: const CommandSchema(positional: ['name'], args: {'name': ArgSpec(required: true, rejectLeadingDash: true), 'dir': ArgSpec()})); + + d.register('project.init', (req) async { + final path = (req.args['dir'] as String?)?.trim() ?? defaultInitPath?.call(); + if (path == null || path.isEmpty) return _err(req.id, 'no directory to initialize', hint: 'pass --dir '); + final result = await initExistingProject(path: path, gitInit: gitInit); + if (!result.ok) return _err(req.id, result.error!); + return IpcResponse.ok(id: req.id, data: {'path': result.path}); + }, schema: const CommandSchema(args: {'dir': ArgSpec()})); } IpcResponse _err(String id, String message, {String? hint}) => IpcResponse.err( diff --git a/test/builtin/welcome/new_project_dialog_test.dart b/test/builtin/welcome/new_project_dialog_test.dart index 114c9f17..96fbbf4d 100644 --- a/test/builtin/welcome/new_project_dialog_test.dart +++ b/test/builtin/welcome/new_project_dialog_test.dart @@ -9,6 +9,7 @@ import 'package:clide/builtin/welcome/src/welcome_view.dart'; import 'package:clide/clide.dart'; import 'package:clide/kernel/kernel.dart'; import 'package:clide/src/daemon/project_commands.dart' show projectCreatedChannel; +import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -79,4 +80,36 @@ void main() { expect(called, isFalse); expect(find.text('Enter a project name.'), findsOneWidget); }); + + testWidgets('opening a non-repo folder offers Initialize, which dispatches project.init + announces (T-489)', (tester) async { + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(const MethodChannel('clide/window'), (call) async { + return call.method == 'pickDirectory' ? '/tmp/not-a-repo' : null; + }); + addTearDown(() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(const MethodChannel('clide/window'), null)); + Map? initArgs; + f.ipc.setConnected(true); + f.ipc.stub('project.init', (args) async { + initArgs = args; + return IpcResponse.ok(id: 'r', data: {'path': '/tmp/not-a-repo'}); + }); + final announced = >[]; + final sub = f.services.messages.subscribe(channel: projectCreatedChannel).listen((m) => announced.add(m.data)); + addTearDown(sub.cancel); + + tester.view.physicalSize = const Size(1200, 900); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + await tester.pumpWidget(_harness(f, const WelcomeView())); + await tester.pumpAndSettle(); + await tester.tap(find.text('Open folder…')); + await tester.pumpAndSettle(); + expect(find.text('Initialize project'), findsOneWidget); + + await tester.tap(find.text('Initialize project')); + await tester.pumpAndSettle(); + expect((initArgs?['flags'] as Map)['dir'], '/tmp/not-a-repo'); + expect(announced.single['dir'], '/tmp/not-a-repo'); + }); } diff --git a/test/daemon/project_commands_test.dart b/test/daemon/project_commands_test.dart index 7bfded67..2a354dd7 100644 --- a/test/daemon/project_commands_test.dart +++ b/test/daemon/project_commands_test.dart @@ -57,6 +57,43 @@ void main() { }); }); + group('initExistingProject + project.init', () { + test('inits an existing folder, scaffolds absent files, never clobbers', () async { + final inited = []; + File('${parent.path}/CLAUDE.md').writeAsStringSync('keep me'); + final r = await initExistingProject(path: parent.path, gitInit: (d) async => inited.add(d)); + expect(r.ok, isTrue, reason: r.error); + expect(r.path, parent.path); + expect(inited, [parent.path]); + expect(File('${parent.path}/CLAUDE.md').readAsStringSync(), 'keep me', reason: 'existing file untouched'); + expect(File('${parent.path}/.gitignore').existsSync(), isTrue, reason: 'absent file scaffolded'); + }); + + test('refuses a missing directory', () async { + expect((await initExistingProject(path: '/no/such/dir/xyz', gitInit: (_) async {})).error, contains('does not exist')); + }); + + test('project.init verb inits --dir, and falls back to the default init path', () async { + final d = DaemonDispatcher(); + registerProjectCommands(d, gitInit: (_) async {}, defaultInitPath: () => parent.path); + final r1 = await d.dispatch( + IpcRequest( + id: '1', + cmd: 'project.init', + args: { + 'positional': const [], + 'flags': {'dir': parent.path}, + }, + ), + ); + expect(r1.ok, isTrue, reason: r1.error?.message); + expect(r1.data['path'], parent.path); + final r2 = await d.dispatch(IpcRequest(id: '2', cmd: 'project.init', args: {'positional': const [], 'flags': const {}})); + expect(r2.ok, isTrue, reason: r2.error?.message); + expect(r2.data['path'], parent.path); + }); + }); + group('project.new command', () { Future run(List positional, {Map? flags, String? defaultParent}) { final d = DaemonDispatcher();