feat(cli): clide project new — create + git-init a new project (T-487)
The backend half of the new-project flow (story T-486). createNewProject validates the name, makes <parent>/<name>/, runs git init (injected from the toolchain in main.dart so the handler stays Flutter-free), and writes a minimal scaffold (.gitignore + a CLAUDE.md stub). The project.new verb wraps it; --dir defaults to the current workspace's parent so a new project lands beside it. Create-only by design — opening the new workspace and the account roadblock are the UI flow's job (T-488). Closes T-487. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7260,3 +7260,4 @@ Considerations:
|
||||
2. The post-/clear respawn also gets it (it spawns through the same path).
|
||||
3. A --resume of an existing session does NOT get re-injected.
|
||||
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;
|
||||
|
||||
@@ -9353,3 +9353,4 @@ Considerations:
|
||||
2. The post-/clear respawn also gets it (it spawns through the same path).
|
||||
3. A --resume of an existing session does NOT get re-injected.
|
||||
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);
|
||||
|
||||
@@ -18,6 +18,9 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
|
||||
|
||||
### Added
|
||||
|
||||
- **`clide project new <name> [--dir <parent>]`.** 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)
|
||||
- **Claude account login pane.** `account login` (and the UI add/re-login
|
||||
affordances) open a modal terminal running `CLAUDE_CONFIG_DIR=<dir> claude
|
||||
login`; the CLI owns the OAuth flow, credentials land in that account's dir.
|
||||
|
||||
@@ -43,6 +43,7 @@ import 'package:clide/src/daemon/editor_commands.dart';
|
||||
import 'package:clide/src/daemon/files_commands.dart';
|
||||
import 'package:clide/src/daemon/git_commands.dart';
|
||||
import 'package:clide/src/daemon/image_commands.dart';
|
||||
import 'package:clide/src/daemon/project_commands.dart';
|
||||
import 'package:clide/src/daemon/instance_command.dart';
|
||||
import 'package:clide/src/daemon/log_commands.dart';
|
||||
import 'package:clide/src/daemon/pane_commands.dart';
|
||||
@@ -332,6 +333,14 @@ Future<void> main() async {
|
||||
registerEditorCommands(dispatcher, editorRegistry);
|
||||
final gitClient = GitClient(toolchain: tc, workDir: workRoot);
|
||||
registerGitCommands(dispatcher, gitClient, eventSink);
|
||||
// `clide project new <name>` (T-487): create + git-init a new project dir.
|
||||
// git init runs over the *new* dir via the toolchain; --dir defaults to the
|
||||
// current workspace's parent so a new project lands beside this one.
|
||||
registerProjectCommands(
|
||||
dispatcher,
|
||||
gitInit: (dir) => GitClient(toolchain: tc, workDir: Directory(dir)).init(),
|
||||
defaultParent: () => workRoot.parent.path,
|
||||
);
|
||||
final pql = PqlClient(workDir: workRoot, toolchain: tc);
|
||||
registerPqlCommands(dispatcher, pql);
|
||||
registerPanelCommands(dispatcher, ArrangementPanelResizer(arrangement));
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/// `clide project new <name> [--dir <parent>]` — create a new clide project
|
||||
/// (T-487, story T-486). clide treats a git repo as the workspace, so a new
|
||||
/// project is: a fresh directory, `git init`, and a minimal scaffold. The git
|
||||
/// binary lives behind the toolchain in main.dart, so it's injected here as
|
||||
/// [ProjectGitInit], keeping this handler Flutter-free (runs under `dart test`).
|
||||
///
|
||||
/// This verb creates only — opening the new workspace and the account roadblock
|
||||
/// (T-488) are the UI flow's job; the CLI returns the created path.
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import '../ipc/command_schema.dart';
|
||||
import '../ipc/envelope.dart';
|
||||
import '../ipc/schema_v1.dart';
|
||||
import 'dispatcher.dart';
|
||||
|
||||
/// Runs `git init` in [dir]. Injected so this stays Flutter-free + testable;
|
||||
/// main.dart wires it to the real toolchain.
|
||||
typedef ProjectGitInit = Future<void> Function(String dir);
|
||||
|
||||
/// Outcome of a [createNewProject] attempt — the created path, or a clear error.
|
||||
class NewProjectResult {
|
||||
const NewProjectResult.ok(String this.path) : error = null;
|
||||
const NewProjectResult.err(String this.error) : path = null;
|
||||
final String? path;
|
||||
final String? error;
|
||||
bool get ok => error == null;
|
||||
}
|
||||
|
||||
/// Validate a project name: a single folder segment, not a path or a dot-name.
|
||||
String? validateProjectName(String name) {
|
||||
final n = name.trim();
|
||||
if (n.isEmpty) return 'project name is required';
|
||||
if (n.contains('/') || n.contains(r'\')) return 'name must be a single folder, not a path';
|
||||
if (n == '.' || n == '..' || n.startsWith('.')) return 'invalid project name: "$name"';
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Create `<parent>/<name>/`, `git init` it (via [gitInit]), and write a minimal
|
||||
/// scaffold. Never overwrites an existing entry. Returns the created path or a
|
||||
/// clear, user-facing error.
|
||||
Future<NewProjectResult> createNewProject({required String parent, required String name, required ProjectGitInit gitInit}) async {
|
||||
final nameErr = validateProjectName(name);
|
||||
if (nameErr != null) return NewProjectResult.err(nameErr);
|
||||
|
||||
final trimmedParent = _stripTrailingSep(parent.trim());
|
||||
if (trimmedParent.isEmpty) return NewProjectResult.err('a parent directory is required');
|
||||
if (!Directory(trimmedParent).existsSync()) return NewProjectResult.err('parent directory does not exist: $trimmedParent');
|
||||
|
||||
final target = '$trimmedParent/${name.trim()}';
|
||||
if (Directory(target).existsSync() || File(target).existsSync()) {
|
||||
return NewProjectResult.err('already exists: $target');
|
||||
}
|
||||
|
||||
Directory(target).createSync(recursive: true);
|
||||
await gitInit(target);
|
||||
_writeScaffold(target, name.trim());
|
||||
return NewProjectResult.ok(target);
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
String _stripTrailingSep(String p) {
|
||||
var s = p;
|
||||
while (s.length > 1 && (s.endsWith('/') || s.endsWith(r'\'))) {
|
||||
s = s.substring(0, s.length - 1);
|
||||
}
|
||||
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}) {
|
||||
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 <name>');
|
||||
final parent = (req.args['dir'] as String?)?.trim() ?? defaultParent?.call();
|
||||
if (parent == null || parent.isEmpty) {
|
||||
return _err(req.id, 'no parent directory to create in', hint: 'pass --dir <parent>');
|
||||
}
|
||||
final result = await createNewProject(parent: parent, name: name, gitInit: gitInit);
|
||||
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()}));
|
||||
}
|
||||
|
||||
IpcResponse _err(String id, String message, {String? hint}) => IpcResponse.err(
|
||||
id: id,
|
||||
error: IpcError(code: IpcExitCode.userError, kind: IpcErrorKind.userError, message: message, hint: hint),
|
||||
);
|
||||
@@ -0,0 +1,85 @@
|
||||
/// Tests for `project.new` (T-487, story T-486): name validation, the
|
||||
/// create-dir + scaffold + git-init service, and the dispatcher verb. git init
|
||||
/// is injected as a fake, so these run Flutter-free with real temp dirs.
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/src/daemon/project_commands.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
late Directory parent;
|
||||
setUp(() async => parent = await Directory.systemTemp.createTemp('clide-newproj-'));
|
||||
tearDown(() {
|
||||
if (parent.existsSync()) parent.deleteSync(recursive: true);
|
||||
});
|
||||
|
||||
group('validateProjectName', () {
|
||||
test('rejects empty, paths, and dot-names; accepts a plain folder name', () {
|
||||
expect(validateProjectName(''), isNotNull);
|
||||
expect(validateProjectName('a/b'), isNotNull);
|
||||
expect(validateProjectName('.'), isNotNull);
|
||||
expect(validateProjectName('..'), isNotNull);
|
||||
expect(validateProjectName('.hidden'), isNotNull);
|
||||
expect(validateProjectName('my-app'), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('createNewProject', () {
|
||||
test('creates the dir + scaffold and runs git init', () async {
|
||||
final inited = <String>[];
|
||||
final r = await createNewProject(parent: parent.path, name: 'my-app', gitInit: (d) async => inited.add(d));
|
||||
expect(r.ok, isTrue, reason: r.error);
|
||||
final target = '${parent.path}/my-app';
|
||||
expect(r.path, target);
|
||||
expect(Directory(target).existsSync(), isTrue);
|
||||
expect(File('$target/.gitignore').existsSync(), isTrue);
|
||||
expect(File('$target/CLAUDE.md').readAsStringSync(), contains('my-app'));
|
||||
expect(inited, [target]);
|
||||
});
|
||||
|
||||
test('refuses an existing target without touching it', () async {
|
||||
Directory('${parent.path}/taken').createSync();
|
||||
File('${parent.path}/taken/keep.txt').writeAsStringSync('x');
|
||||
var gitRan = false;
|
||||
final r = await createNewProject(parent: parent.path, name: 'taken', gitInit: (_) async => gitRan = true);
|
||||
expect(r.ok, isFalse);
|
||||
expect(r.error, contains('already exists'));
|
||||
expect(gitRan, isFalse);
|
||||
expect(File('${parent.path}/taken/keep.txt').existsSync(), isTrue);
|
||||
});
|
||||
|
||||
test('refuses a missing parent and a bad name', () async {
|
||||
expect((await createNewProject(parent: '/no/such/parent/xyz', name: 'a', gitInit: (_) async {})).error, contains('does not exist'));
|
||||
expect((await createNewProject(parent: parent.path, name: 'a/b', gitInit: (_) async {})).error, isNotNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('project.new command', () {
|
||||
Future<IpcResponse> run(List<String> positional, {Map<String, Object?>? flags, String? defaultParent}) {
|
||||
final d = DaemonDispatcher();
|
||||
registerProjectCommands(d, gitInit: (_) async {}, defaultParent: () => defaultParent);
|
||||
return d.dispatch(IpcRequest(id: '1', cmd: 'project.new', args: {'positional': positional, 'flags': ?flags}));
|
||||
}
|
||||
|
||||
test('creates under --dir and returns the path', () async {
|
||||
final r = await run(['my-app'], flags: {'dir': parent.path});
|
||||
expect(r.ok, isTrue, reason: r.error?.message);
|
||||
expect(r.data['path'], '${parent.path}/my-app');
|
||||
expect(r.data['name'], 'my-app');
|
||||
});
|
||||
|
||||
test('falls back to the default parent when --dir is omitted', () async {
|
||||
final r = await run(['my-app'], defaultParent: parent.path);
|
||||
expect(r.ok, isTrue, reason: r.error?.message);
|
||||
expect(r.data['path'], '${parent.path}/my-app');
|
||||
});
|
||||
|
||||
test('errors with no name, and with no parent available', () async {
|
||||
expect((await run([])).ok, isFalse, reason: 'name is required');
|
||||
expect((await run(['x'])).ok, isFalse, reason: 'no --dir and no default parent');
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user