Skip to content

Commit c69b3be

Browse files
committed
feat(import): support local folder imports and enhance manifest handling
1 parent f7eb62f commit c69b3be

File tree

7 files changed

+213
-28
lines changed

7 files changed

+213
-28
lines changed

src/cli/commands/import.command.ts

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,15 @@
55
* codemachine import <source> Install/update an import
66
* codemachine import --list List installed imports
77
* codemachine import --remove <name> Remove an import
8+
*
9+
* Source formats:
10+
* - Local path: /path/to/folder or ./relative/path (requires .codemachine.json)
11+
* - GitHub: package-name, owner/repo, or https://github.com/...
12+
* - Git URL: git@github.com:user/repo.git
813
*/
914

1015
import type { Command } from 'commander';
11-
import { existsSync, rmSync } from 'node:fs';
16+
import { existsSync, rmSync, cpSync } from 'node:fs';
1217
import { spawn } from 'node:child_process';
1318
import { join } from 'node:path';
1419
import {
@@ -82,6 +87,25 @@ function removeGitDir(repoPath: string): void {
8287
}
8388
}
8489

90+
/**
91+
* Copy a local folder to the imports directory
92+
*/
93+
function copyLocalFolder(
94+
sourcePath: string,
95+
destPath: string,
96+
verbose: boolean
97+
): void {
98+
if (verbose) {
99+
console.log(` Copying from: ${sourcePath}`);
100+
console.log(` Copying to: ${destPath}`);
101+
}
102+
103+
cpSync(sourcePath, destPath, { recursive: true });
104+
105+
// Remove .git directory if present in the copied folder
106+
removeGitDir(destPath);
107+
}
108+
85109
/**
86110
* Install an import from a source
87111
*/
@@ -114,12 +138,18 @@ async function installImport(source: string, verbose: boolean): Promise<void> {
114138
// Ensure imports directory exists
115139
ensureImportsDir();
116140

117-
// Clone the repository
118-
console.log(' Cloning repository...');
119-
await cloneRepo(resolved.url, installPath, verbose);
141+
// Handle local paths vs git URLs
142+
if (resolved.type === 'local-path') {
143+
console.log(' Copying local folder...');
144+
copyLocalFolder(resolved.url, installPath, verbose);
145+
} else {
146+
// Clone the repository
147+
console.log(' Cloning repository...');
148+
await cloneRepo(resolved.url, installPath, verbose);
120149

121-
// Remove .git directory (we don't need version control for imports)
122-
removeGitDir(installPath);
150+
// Remove .git directory (we don't need version control for imports)
151+
removeGitDir(installPath);
152+
}
123153

124154
// Validate the import
125155
console.log(' Validating...');
@@ -198,6 +228,7 @@ function listImports(): void {
198228
console.log(` codemachine import <package-name>`);
199229
console.log(` codemachine import <owner>/<repo>`);
200230
console.log(` codemachine import <https://github.com/...>`);
231+
console.log(` codemachine import </path/to/folder> (local with .codemachine.json)`);
201232
return;
202233
}
203234

@@ -246,6 +277,8 @@ async function runImportCommand(
246277
console.log(` codemachine import <package-name>`);
247278
console.log(` codemachine import <owner>/<repo>`);
248279
console.log(` codemachine import <https://github.com/...>`);
280+
console.log(` codemachine import </path/to/folder> (local path with .codemachine.json)`);
281+
console.log(` codemachine import <./relative/path> (local path with .codemachine.json)`);
249282
console.log('\nOther options:');
250283
console.log(' codemachine import --list List installed imports');
251284
console.log(' codemachine import --remove <name> Remove an import');

src/cli/tui/routes/home/dialogs/import-dialog.tsx

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,45 @@ export interface ImportDialogProps {
3636
}
3737

3838
/**
39-
* Parse input to extract owner/repo and generate GitHub URL
39+
* Parse input to extract owner/repo, GitHub URL, or local path
4040
*/
41-
function parseGitHubInput(input: string): { owner?: string; repo: string; url?: string } | null {
41+
function parseGitHubInput(input: string): { owner?: string; repo: string; url?: string; isLocal?: boolean } | null {
4242
const trimmed = input.trim()
4343
if (!trimmed) return null
4444

45+
// Local path: absolute path starting with /
46+
if (trimmed.startsWith('/')) {
47+
const parts = trimmed.split('/')
48+
const repo = parts[parts.length - 1] || 'local-import'
49+
return {
50+
repo,
51+
url: trimmed,
52+
isLocal: true,
53+
}
54+
}
55+
56+
// Local path: relative path starting with ./ or ../
57+
if (trimmed.startsWith('./') || trimmed.startsWith('../')) {
58+
const parts = trimmed.split('/')
59+
const repo = parts[parts.length - 1] || 'local-import'
60+
return {
61+
repo,
62+
url: trimmed,
63+
isLocal: true,
64+
}
65+
}
66+
67+
// Local path: home directory shorthand ~/
68+
if (trimmed.startsWith('~/')) {
69+
const parts = trimmed.split('/')
70+
const repo = parts[parts.length - 1] || 'local-import'
71+
return {
72+
repo,
73+
url: trimmed,
74+
isLocal: true,
75+
}
76+
}
77+
4578
// Full GitHub URL: https://github.com/owner/repo or https://github.com/owner/repo.git
4679
const urlMatch = trimmed.match(/^https?:\/\/github\.com\/([^\/]+)\/([^\/\s]+?)(?:\.git)?$/i)
4780
if (urlMatch) {
@@ -101,11 +134,12 @@ export function ImportDialog(props: ImportDialogProps) {
101134
if (!source) return
102135

103136
appDebug("[ImportDialog] Starting install process")
137+
const isLocalPath = parsed()?.isLocal ?? false
104138
setState({
105139
phase: "installing",
106140
steps: [
107141
{ label: "Resolving source", status: "active" },
108-
{ label: "Cloning repository", status: "pending" },
142+
{ label: isLocalPath ? "Copying folder" : "Cloning repository", status: "pending" },
109143
{ label: "Validating manifest", status: "pending" },
110144
{ label: "Registering package", status: "pending" },
111145
],
@@ -277,7 +311,7 @@ export function ImportDialog(props: ImportDialogProps) {
277311
<box flexDirection="column" width={50}>
278312
<Show when={p}>
279313
<text fg={theme.theme.textMuted} marginBottom={1}>
280-
{p!.owner}/{p!.repo}
314+
{p!.isLocal ? `📁 ${p!.repo}` : p!.owner ? `${p!.owner}/${p!.repo}` : p!.repo}
281315
</text>
282316
</Show>
283317

@@ -298,7 +332,7 @@ export function ImportDialog(props: ImportDialogProps) {
298332
<box flexDirection="column" width={50}>
299333
{/* Title */}
300334
<text fg={theme.theme.primary} attributes={1} marginBottom={1}>
301-
⬇ Add Workflow from GitHub
335+
⬇ Add Workflow Package
302336
</text>
303337

304338
{/* Input field */}
@@ -312,7 +346,7 @@ export function ImportDialog(props: ImportDialogProps) {
312346
>
313347
<input
314348
value={inputValue()}
315-
placeholder="owner/repo"
349+
placeholder="owner/repo or /path/to/folder"
316350
onInput={setInputValue}
317351
onPaste={handlePaste}
318352
focused={true}
@@ -325,14 +359,14 @@ export function ImportDialog(props: ImportDialogProps) {
325359
<box marginBottom={1} minHeight={2}>
326360
<Show when={validation().status === "empty"}>
327361
<text fg={theme.theme.textMuted}>
328-
Enter repo name or paste full GitHub URL
362+
Enter repo name, GitHub URL, or local path
329363
</text>
330364
</Show>
331365

332366
<Show when={validation().status === "valid" && parsed()}>
333367
<box flexDirection="column">
334368
<text fg={theme.theme.success}>
335-
{parsed()!.url || parsed()!.repo}
369+
{parsed()!.isLocal ? "📁" : "↳"} {parsed()!.url || parsed()!.repo}
336370
</text>
337371
</box>
338372
</Show>

src/cli/tui/routes/home/hooks/use-home-commands.tsx

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ export function useHomeCommands(options: UseHomeCommandsOptions) {
111111
// Add import option at the top (no category)
112112
const selectOptions = [
113113
{
114-
title: "⬇ Import template from GitHub",
114+
title: "⬇ Import template (GitHub or local)",
115115
value: IMPORT_ACTION,
116116
},
117117
...templateOptions,
@@ -322,7 +322,7 @@ export function useHomeCommands(options: UseHomeCommandsOptions) {
322322
isImportInstalled,
323323
ensureImportsDir,
324324
} = await import("../../../../../shared/imports/index.js")
325-
const { existsSync, rmSync } = await import("node:fs")
325+
const { existsSync, rmSync, cpSync } = await import("node:fs")
326326
const { spawn } = await import("node:child_process")
327327

328328
const cloneRepo = (url: string, destPath: string): Promise<void> => {
@@ -351,6 +351,10 @@ export function useHomeCommands(options: UseHomeCommandsOptions) {
351351
})
352352
}
353353

354+
const copyLocalFolder = (sourcePath: string, destPath: string): void => {
355+
cpSync(sourcePath, destPath, { recursive: true })
356+
}
357+
354358
const removeGitDir = (repoPath: string): void => {
355359
const { join } = require("node:path")
356360
const gitDir = join(repoPath, ".git")
@@ -361,7 +365,7 @@ export function useHomeCommands(options: UseHomeCommandsOptions) {
361365

362366
const performInstall = async (source: string) => {
363367
try {
364-
// Resolve the source to a clone URL
368+
// Resolve the source to a clone URL or local path
365369
const resolved = await resolveSource(source)
366370
const installPath = getImportInstallPath(resolved.repoName)
367371

@@ -373,10 +377,15 @@ export function useHomeCommands(options: UseHomeCommandsOptions) {
373377
// Ensure imports directory exists
374378
ensureImportsDir()
375379

376-
// Clone the repository
377-
await cloneRepo(resolved.url, installPath)
380+
// Handle local paths vs git URLs
381+
if (resolved.type === "local-path") {
382+
copyLocalFolder(resolved.url, installPath)
383+
} else {
384+
// Clone the repository
385+
await cloneRepo(resolved.url, installPath)
386+
}
378387

379-
// Remove .git directory
388+
// Remove .git directory (in case local folder had one)
380389
removeGitDir(installPath)
381390

382391
// Validate the import
@@ -394,9 +403,11 @@ export function useHomeCommands(options: UseHomeCommandsOptions) {
394403
if (hasMissingManifest) {
395404
return {
396405
success: false,
397-
error: "Missing codemachine.json file",
406+
error: "Missing manifest file",
398407
errorDetails:
399-
"The repository must contain a codemachine.json manifest file in the root directory.",
408+
resolved.type === "local-path"
409+
? "The folder must contain a .codemachine.json or codemachine.json manifest file."
410+
: "The repository must contain a codemachine.json manifest file in the root directory.",
400411
}
401412
}
402413

@@ -446,6 +457,22 @@ export function useHomeCommands(options: UseHomeCommandsOptions) {
446457
}
447458
}
448459

460+
if (errorMessage.includes("ENOENT") || errorMessage.includes("no such file")) {
461+
return {
462+
success: false,
463+
error: "Local folder not found",
464+
errorDetails: "Check that the path exists and is accessible.",
465+
}
466+
}
467+
468+
if (errorMessage.includes("EACCES") || errorMessage.includes("permission denied")) {
469+
return {
470+
success: false,
471+
error: "Permission denied",
472+
errorDetails: "Check that you have read access to the folder.",
473+
}
474+
}
475+
449476
return {
450477
success: false,
451478
error: errorMessage,

src/shared/imports/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export {
2929
// Manifest parsing
3030
export {
3131
parseManifest,
32+
findManifestPath,
3233
getResolvedPaths,
3334
validateImport,
3435
getManifestFilename,

src/shared/imports/manifest.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { join } from 'node:path';
77
import type { ImportManifest, ValidationResult } from './types.js';
88

99
const MANIFEST_FILENAME = 'codemachine.json';
10+
const LOCAL_MANIFEST_FILENAME = '.codemachine.json';
1011

1112
/**
1213
* Default paths following CodeMachine conventions
@@ -18,13 +19,32 @@ const DEFAULT_PATHS = {
1819
characters: 'config/agent-characters.json',
1920
};
2021

22+
/**
23+
* Find the manifest file path (checks both codemachine.json and .codemachine.json)
24+
*/
25+
export function findManifestPath(importPath: string): string | null {
26+
// Check for .codemachine.json first (local imports)
27+
const localManifestPath = join(importPath, LOCAL_MANIFEST_FILENAME);
28+
if (existsSync(localManifestPath)) {
29+
return localManifestPath;
30+
}
31+
32+
// Check for codemachine.json (standard imports)
33+
const manifestPath = join(importPath, MANIFEST_FILENAME);
34+
if (existsSync(manifestPath)) {
35+
return manifestPath;
36+
}
37+
38+
return null;
39+
}
40+
2141
/**
2242
* Parse a manifest file from a directory
2343
*/
2444
export function parseManifest(importPath: string): ImportManifest | null {
25-
const manifestPath = join(importPath, MANIFEST_FILENAME);
45+
const manifestPath = findManifestPath(importPath);
2646

27-
if (!existsSync(manifestPath)) {
47+
if (!manifestPath) {
2848
return null;
2949
}
3050

@@ -66,7 +86,7 @@ export function validateImport(importPath: string): ValidationResult {
6686
// Check manifest exists
6787
const manifest = parseManifest(importPath);
6888
if (!manifest) {
69-
errors.push(`Missing ${MANIFEST_FILENAME} file`);
89+
errors.push(`Missing manifest file (${MANIFEST_FILENAME} or ${LOCAL_MANIFEST_FILENAME})`);
7090
return { valid: false, errors, warnings };
7191
}
7292

0 commit comments

Comments
 (0)