Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
182 changes: 154 additions & 28 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,50 +1,176 @@
# Coding Agent Guide for Construct

## Build/Lint/Test Commands
### Core Commands```bash
bun run index.ts --list bun run build # Build current platform
bun run build:all # Build all platforms (linux-x64, arm-64, macos-..., windows)bun run build:macos-arm64 # Build specific platform
```### Running Single Test (Manual)```bash
# Manual verification steps:1 bun run index.ts --list2 Verify output contains "Available plugins:"3 Test with plugin: bun run index --load playwright@claude-plugins-original
```### Typechecking```bash
# Bun infers tsconfig.json automaticallybun run index.ts --help # Triggers typechecking

### Core Commands
```bash
bun run build # Build current platform
bun run build:all # Build all platforms (linux-x64, linux-arm64, macos-x64, macos-arm64, windows-x64)
bun run build:macos-arm64 # Build specific platform
bun run typecheck # Type checking with TypeScript
```

## Code Style Guidelines
### Running Tests
```bash
# Run all tests
bun test

# Run specific test file
bun test src/plugin.test.ts
bun test src/marketplace.test.ts
bun test src/cache.test.ts
bun test src/env-expansion.test.ts

### TypeScript Configuration (tsconfig.json)Target: ESNext, Module resolution: bundler with verbatim syntax, Strict mode enabled (strict, noUncheckedIndexedAccess), NoEmit: true### Imports Order & Style1 Node built-ins use explicit `node:` prefix: `import { join } from "node:path";`
2 External libraries import directly: `import yargs from "yargs";`3 Type imports use explicit `type` keyword### Interface Naming (PascalCase)CliArgs, PluginInfo, ConstructConfig, TranslationResult
# Run tests with coverage
bun test --coverage
```

### Manual Verification
```bash
# List available plugins
bun run index.ts --list

### Variable/Constant Naming (camelCase/SCREAMING_SCAL_Constants: CONFIG_FILE = ".construct.json", Variables: cliPlugins, enabledPluginNames
# Verify output contains "Available plugins:"
bun run index.ts --list | grep "Available plugins"

### Function Naming (camelCase)parseCliArgs, scanAllPlugins, loadConfig, saveConfig, mergeCliWithConfig### File Organization (src/)cli.ts: CLI argument parsing (yargs)scanner.ts: Plugin discovery/indexing (scanAllPlugins, scanInstalledPlugins)config.ts: Configuration management (.construct.json)translator.ts: Format translation (translatePlugins, expandPluginRootInObject)executor.ts: Copilot subprocess spawningcompletions.ts: Shell completion script generation
# Test with specific plugin
bun run index.ts --load playwright@claude-plugins-original

### Error Handling Patterns1 Console.warn for non-critical errors (missing files, scanning issues)2 Console.error for critical failures (file I/O, parsing errors)
3 Try/catch wrap file system operations and JSON parsing4 Functions return null on graceful failure (loadConfig, readMcpConfig)
# Test plugin scanning
bun run index.ts --list

### Async/await Style- Top-level async function: `async function main(): Promise<void>`- Try/catch in async context, Concurrent via Promise.all
# Verify configuration persistence
cat .construct.json
```

### JSDoc Comments (TSDoc on exported symbols)```typescript
## Code Style Guidelines

### TypeScript Configuration (tsconfig.json)
- Target: ESNext
- Module resolution: bundler with verbatim syntax
- Strict mode enabled (strict, noUncheckedIndexedAccess, noFallthroughCasesInSwitch, noImplicitOverride)
- NoEmit: true
- Module: Preserve
- Module detection: force

### Imports Order & Style
1. Node built-ins use explicit `node:` prefix: `import { join } from "node:path";`
2. External libraries import directly: `import yargs from "yargs";`
3. Type imports use explicit `type` keyword: `import type { PluginInfo } from "./scanner";`

### Naming Conventions
- **Interfaces**: PascalCase (CliArgs, PluginInfo, ConstructConfig, TranslationResult, PluginComponent)
- **Variables**: camelCase (cliPlugins, enabledPluginNames, pluginCachePaths)
- **Constants**: SCREAMING_SNAKE_CASE (CONFIG_FILE = ".construct.json")
- **Functions**: camelCase (parseCliArgs, scanAllPlugins, loadConfig, saveConfig, mergeCliWithConfig)

### File Organization (src/)
- `cli.ts`: CLI argument parsing (yargs)
- `scanner.ts`: Plugin discovery and indexing (scanAllPlugins, scanInstalledPlugins, scanMarketplacePlugins)
- `config.ts`: Configuration management (.construct.json)
- `translator.ts`: Format translation (translatePlugins, expandPluginRootInObject)
- `executor.ts`: Copilot subprocess spawning
- `completions.ts`: Shell completion script generation
- `plugin.ts`: Plugin management
- `marketplace.ts`: Marketplace operations
- `cache.ts`: Plugin caching
- `agent-translator.ts`: Agent format translation
- `skill-translator.ts`: Skill format translation
- `operator.ts`: Interactive plugin selector
- `env-expansion.ts`: Environment variable expansion

### Error Handling Patterns
1. `console.warn` for non-critical errors (missing files, scanning issues, failed agent translation)
2. `console.error` for critical failures (file I/O, parsing errors, MCP config reading)
3. Try/catch wrap file system operations and JSON parsing
4. Functions return `null` on graceful failure (loadConfig, readMcpConfig)
5. Catch blocks should log errors but not crash the application

### Async/await Style
- Top-level async function: `async function main(): Promise<void>`
- Try/catch in async context
- Concurrent operations via `Promise.all()`
- Use `await` for file operations and async function calls

### JSDoc Comments (TSDoc on exported symbols)
```typescript
/**
* Represents a single component within a plugin (skill, MCP server, or agent) */export interface PluginComponent {}
* Represents a single component within a plugin (skill, MCP server, or agent)
*/
export interface PluginComponent {}

/** Scans all installed plugins and builds a registry */
}```### Custom Types (avoid "any")Explicit interfaces: PluginInfo, PluginComponent, Generic type parameters: <T>, Return types on all functions
/**
* Scans all installed plugins and builds a registry
*/
export async function scanAllPlugins(): Promise<PluginRegistry>
```

### Structure (all source files)1 Imports section2 Type/interface definitions3 Constant/module-level declarations4 Function implementations (exported and private)
### Custom Types
- Avoid "any" types - use explicit interfaces
- Use generic type parameters: `<T>`, `<T extends PluginInfo>`
- Define return types on all functions
- Use union types for multiple possibilities: `'skill' | 'mcp' | 'agent'`

### Naming Convention: PluginsFormat: `<plugin-name>@<marketplace-name>`, Example: `tmux@scaryrawr-plugins`
### Structure (all source files)
1. Imports section
2. Type/interface definitions
3. Constant/module-level declarations
4. Function implementations (exported and private)

### File Paths (Absolute, no relative)Use `join` for path construction: `const configPath = join(process.cwd(), CONFIG_FILE)`
### Naming Convention: Plugins
Format: `<plugin-name>@<marketplace-name>`
Example: `tmux@scaryrawr-plugins`

### Project Structure```src/├── cli.ts CLI argument parsing├── scanner.ts Plugin discovery and indexing├── config.ts Configuration management├── translator.ts Format translation logic└── executor.ts Copilot subprocess execution
```
### File Paths
- Use absolute paths with `join()` from `node:path`
- Example: `const configPath = join(proc.cwd(), CONFIG_FILE)` where `proc` is an injected process-like dependency

### Environment Variables- `COPILOT_SKILLS_DIRS`: Comma-separated list of skill directories
### Environment Variables
- `COPILOT_SKILLS_DIRS`: Comma-separated list of skill directories
- `CLAUDE_PLUGIN_ROOT`: Placeholder for plugin root path (expanded during translation)

### Testing Manual```bash
# Test plugin scanning: bun run index --list# Verify configuration persistence: cat .construct.json
### Common Issues
1. **No Plugins Found**: `installed_plugins.json` missing or empty - Install plugins via Claude Code first
2. **Plugin Not Found**: Name mismatch (case-sensitive) - Use exact format from `installed_plugins.json`
3. **MCP Servers Not Working**: Invalid `.mcp.json` - Validate JSON and required fields
4. **Skills Not Loading**: `COPILOT_SKILLS_DIRS` not set - Check environment variable construction
5. **Type Errors**: Run `bun run typecheck` to verify TypeScript configuration

## Testing Patterns

### Dependency Injection
All core modules use optional dependency injection for testability:
- `config.ts` - `ConfigDependencies` with `fs`, `process`
- `scanner.ts` - `ScannerDependencies` with `fs`, `process`
- `marketplace.ts` - `MarketplaceDependencies` with `fs`, `shell`, paths
- `cache.ts` - `CacheDependencies` with `fs`, `process`; returns `CacheInstance`
- `plugin.ts` - `PluginDependencies` with scanner, config, output functions
- `translator.ts` - `TranslatorDependencies` with `cache`, `fs`
- `executor.ts` - `ExecutorDependencies` with `shell`, `env`

### Test Utilities
Import from `./test-utils`:
```typescript
import { createMemoryFileSystem, createMockProcess, createMockShell } from './test-utils';
```

### Common Issues1 No Plugins Found: installed_plugins.json missing or empty - Install plugins via Claude Code first2 Plugin Not Found: Name mismatch (case-sensitive) - Use exact format from installed_plugins.json3 MCP Servers Not Working: Invalid .mcp.0json - Validate JSON and required fields4 Skills Not Loading: COPILOT_SKILLS_DIRS not set - Check environment variable construction
### Unit vs Integration Tests
- **Unit tests** (*.test.ts): Use mocks, no I/O, fast
- **Integration tests** (*.integration.test.ts): Use real file system in temp dirs

### Example Unit Test
```typescript
import { describe, expect, test } from 'bun:test';
import { createMemoryFileSystem, createMockProcess } from './test-utils';

test('example with mocks', async () => {
const fs = createMemoryFileSystem()
.withFile('/home/.construct.json', '{"enabledPlugins":[]}')
.build();
const proc = createMockProcess({ cwd: '/work', homedir: '/home' });

// Call function with injected deps
const result = await someFunction({ fs, process: proc });
expect(result).toBeDefined();
});
```
150 changes: 150 additions & 0 deletions src/adapters/bun-file-system.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import type { FileSystem, FileStat, MkdirOptions, RmOptions, CpOptions } from '../interfaces/file-system';
import * as fs from 'fs';
import * as fsPromises from 'fs/promises';
import * as path from 'path';

/**
* File stat wrapper for Node.js fs.Stats
*/
class BunFileStat implements FileStat {
constructor(private stats: fs.Stats) {}

isDirectory(): boolean {
return this.stats.isDirectory();
}

isFile(): boolean {
return this.stats.isFile();
}
}

/**
* FileSystem implementation using Bun APIs with Node.js fallbacks
*/
class BunFileSystem implements FileSystem {
/**
* Reads file content as UTF-8 string using Bun.file()
*/
async readFile(filePath: string): Promise<string> {
try {
const file = Bun.file(filePath);
return await file.text();
} catch (error) {
throw new Error(`Failed to read file ${filePath}: ${error instanceof Error ? error.message : String(error)}`);
}
}

/**
* Writes content to file using Bun.write(), creating parent directories if needed
*/
async writeFile(filePath: string, content: string): Promise<void> {
try {
// Ensure parent directory exists
const dir = path.dirname(filePath);
await this.mkdir(dir, { recursive: true });

// Write file using Bun.write()
await Bun.write(filePath, content);
} catch (error) {
throw new Error(`Failed to write file ${filePath}: ${error instanceof Error ? error.message : String(error)}`);
}
}

/**
* Checks if path exists (file or directory)
*/
async exists(filePath: string): Promise<boolean> {
try {
// First try as file
const file = Bun.file(filePath);
if (await file.exists()) {
return true;
}
// Fall back to stat for directories
await fsPromises.stat(filePath);
return true;
} catch (error) {
return false;
}
}

/**
* Creates directory using Node.js fs.mkdir (with promises)
*/
async mkdir(dirPath: string, options?: MkdirOptions): Promise<void> {
try {
await fsPromises.mkdir(dirPath, {
recursive: options?.recursive ?? false,
});
} catch (error) {
// EEXIST is not an error if recursive is true
if ((error as NodeJS.ErrnoException)?.code === 'EEXIST' && options?.recursive) {
return;
}
throw new Error(`Failed to create directory ${dirPath}: ${error instanceof Error ? error.message : String(error)}`);
}
}

/**
* Removes file or directory using Node.js fs.rm (with promises)
*/
async rm(filePath: string, options?: RmOptions): Promise<void> {
try {
await fsPromises.rm(filePath, {
recursive: options?.recursive ?? false,
force: options?.force ?? false,
});
} catch (error) {
// ENOENT is not an error if force is true
if ((error as NodeJS.ErrnoException)?.code === 'ENOENT' && options?.force) {
return;
}
throw new Error(`Failed to remove ${filePath}: ${error instanceof Error ? error.message : String(error)}`);
}
}

/**
* Lists directory contents using Node.js fs.readdir (with promises)
*/
async readdir(dirPath: string): Promise<string[]> {
try {
const entries = await fsPromises.readdir(dirPath);
return entries;
} catch (error) {
throw new Error(`Failed to read directory ${dirPath}: ${error instanceof Error ? error.message : String(error)}`);
}
}

/**
* Gets file/directory stats using Node.js fs.stat (with promises)
*/
async stat(filePath: string): Promise<FileStat> {
try {
const stats = await fsPromises.stat(filePath);
return new BunFileStat(stats);
} catch (error) {
throw new Error(`Failed to stat ${filePath}: ${error instanceof Error ? error.message : String(error)}`);
}
}

/**
* Copies file or directory using Node.js fs/promises cp
*/
async cp(src: string, dest: string, options?: CpOptions): Promise<void> {
try {
await fsPromises.cp(src, dest, {
recursive: options?.recursive ?? false,
force: options?.force ?? false,
});
} catch (error) {
throw new Error(`Failed to copy ${src} to ${dest}: ${error instanceof Error ? error.message : String(error)}`);
}
}
}

/**
* Singleton instance for convenient access
*/
export const bunFileSystem = new BunFileSystem();

export { BunFileSystem };
35 changes: 35 additions & 0 deletions src/adapters/bun-shell.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { Shell, SpawnOptions, SpawnSyncResult } from "../interfaces/shell";

/**
* Shell adapter implementation using Bun.spawnSync
*/
export class BunShell implements Shell {
/**
* Spawns a command synchronously and waits for completion
*/
spawnSync(cmd: string[], options?: SpawnOptions): SpawnSyncResult {
// Map SpawnOptions to Bun spawn options
const bunOptions: Parameters<typeof Bun.spawnSync>[1] = {
cwd: options?.cwd,
env: options?.env ? { ...process.env, ...options.env } : undefined,
stdout: options?.stdout === "pipe" ? "pipe" : options?.stdout,
stderr: options?.stderr === "pipe" ? "pipe" : options?.stderr,
stdin: options?.stdin === "pipe" ? "pipe" : options?.stdin,
};

// Execute the command
const result = Bun.spawnSync(cmd, bunOptions);

// Convert Bun result to SpawnSyncResult
return {
exitCode: result.exitCode,
stdout: result.stdout ?? new Uint8Array(),
stderr: result.stderr ?? new Uint8Array(),
};
}
}

/**
* Singleton instance for convenient access
*/
export const bunShell = new BunShell();
Loading