diff --git a/.agents/plans/completed/archon-distribution-config.plan.md b/.agents/plans/completed/archon-distribution-config.plan.md new file mode 100644 index 00000000..933e5b47 --- /dev/null +++ b/.agents/plans/completed/archon-distribution-config.plan.md @@ -0,0 +1,2831 @@ +# Feature: Archon Distribution & Configuration System + +The following plan should be complete, but it's important that you validate documentation and codebase patterns and task sanity before you start implementing. + +Pay special attention to naming of existing utils types and models. Import from the right files etc. + +## Feature Description + +A comprehensive distribution and configuration system for the remote-coding-agent that establishes: +1. **Unified directory structure** under `~/.archon/` for all managed files (workspaces, worktrees, config) +2. **Docker image publishing** to GitHub Container Registry (GHCR) for easy distribution +3. **YAML-based configuration** using Bun's native YAML support for non-secret settings +4. **Config precedence chain**: Environment variables > `~/.archon/config.yaml` > `.archon/config.yaml` > defaults + +This foundation enables future workflow engine development, where workflows will be defined in YAML and orchestrated by the system. + +## User Story + +As a self-hosting developer +I want a clean directory structure and simple Docker deployment +So that I can run the remote-coding-agent without building from source and organize my workspaces consistently + +As a future workflow builder user +I want configuration in YAML format +So that workflows can be version-controlled and composed from reusable definitions + +## Problem Statement + +**Current issues:** +1. **Path pollution**: `WORKSPACE_PATH` defaults to `./workspace` inside project, causing test pollution and IDE confusion +2. **No published images**: Users must clone repo and build Docker images locally +3. **Flat configuration**: `.env` is the only config mechanism, limiting complex/nested settings +4. **Inconsistent paths**: Workspaces and worktrees use different path resolution logic scattered across files + +## Solution Statement + +1. Introduce unified `~/.archon/` directory structure with centralized path resolution +2. Publish multi-arch Docker images to GHCR via GitHub Actions +3. Add YAML configuration support using Bun's native `Bun.YAML.parse()` for non-secret settings +4. Establish clear config precedence: secrets in `.env`, structured config in YAML files + +## Feature Metadata + +**Feature Type**: New Capability + Refactor +**Estimated Complexity**: Medium-High +**Primary Systems Affected**: Path resolution, Docker build, Configuration loading, Startup +**Dependencies**: Bun native YAML support (built-in), GitHub Actions, GHCR + +--- + +## CONTEXT REFERENCES + +### Relevant Codebase Files IMPORTANT: YOU MUST READ THESE FILES BEFORE IMPLEMENTING! + +- `src/utils/git.ts` (lines 28-62) - Why: Contains current path resolution pattern with Docker detection and tilde expansion +- `src/utils/path-validation.ts` (lines 1-51) - Why: Uses WORKSPACE_ROOT constant, needs to use dynamic resolution +- `src/handlers/command-handler.ts` (lines 31, 198, 255, 287-295) - Why: Uses WORKSPACE_PATH and command folder detection +- `src/adapters/github.ts` (lines 362, 411) - Why: Uses WORKSPACE_PATH for repo cloning +- `src/index.ts` (lines 67-76) - Why: Startup path validation and warnings +- `.github/workflows/test.yml` - Why: Pattern for GitHub Actions workflow structure +- `Dockerfile` (lines 1-67) - Why: Current Docker build configuration +- `docker-compose.yml` - Why: Current compose configuration with volume mounts +- `src/types/index.ts` - Why: Type definitions pattern + +### New Files to Create + +- `src/utils/archon-paths.ts` - Centralized path resolution for all Archon directories +- `src/utils/archon-paths.test.ts` - Tests for path resolution +- `src/config/loader.ts` - YAML configuration loading with precedence chain +- `src/config/loader.test.ts` - Tests for config loading +- `src/config/types.ts` - Configuration type definitions +- `.github/workflows/publish.yml` - Docker and npm publishing workflow +- `deploy/docker-compose.yml` - Minimal compose for end users +- `deploy/.env.example` - Minimal env example for end users +- `scripts/validate-setup.sh` - Setup validation script +- `docs/configuration.md` - Comprehensive configuration reference +- `docs/getting-started.md` - Step-by-step setup guide for new users +- `docs/archon-architecture.md` - Technical architecture documentation for developers + +### Relevant Documentation YOU SHOULD READ THESE BEFORE IMPLEMENTING! + +- [Bun YAML Documentation](https://bun.sh/docs/api/yaml) + - Native `Bun.YAML.parse()` API + - Why: No external dependencies needed for YAML parsing +- [GitHub Container Registry Docs](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry) + - Publishing and pulling images + - Why: GHCR publishing workflow +- [Docker Build-Push Action](https://github.com/docker/build-push-action) + - Multi-arch builds, caching + - Why: GitHub Actions Docker publishing + +### Patterns to Follow + +**Path Resolution Pattern (from `src/utils/git.ts:33-51`):** +```typescript +export function getWorktreeBase(_repoPath: string): string { + // 1. Docker: FIXED location, no override for end users + const isDocker = + process.env.WORKSPACE_PATH === '/workspace' || + (process.env.HOME === '/root' && process.env.WORKSPACE_PATH); + + if (isDocker) { + return '/workspace/worktrees'; + } + + // 2. Local: Check env override + const envBase = process.env.WORKTREE_BASE; + if (envBase) { + return expandTilde(envBase); + } + + // 3. Local default + return join(homedir(), 'tmp', 'worktrees'); +} +``` + +**Tilde Expansion Pattern (from `src/utils/git.ts:56-62`):** +```typescript +function expandTilde(path: string): string { + if (path.startsWith('~')) { + const pathAfterTilde = path.slice(1).replace(/^[/\\]/, ''); + return join(homedir(), pathAfterTilde); + } + return path; +} +``` + +**Command Folder Detection Pattern (from `src/handlers/command-handler.ts:287-295`):** +```typescript +for (const folder of ['.claude/commands', '.agents/commands']) { + try { + await access(join(targetPath, folder)); + commandFolder = folder; + break; + } catch { + /* ignore */ + } +} +``` + +**Test Pattern with Env Mocking (from `src/utils/git.test.ts`):** +```typescript +describe('path resolution', () => { + const originalEnv: Record = {}; + + beforeEach(() => { + // Save original values + ['WORKSPACE_PATH', 'WORKTREE_BASE'].forEach(key => { + originalEnv[key] = process.env[key]; + }); + }); + + afterEach(() => { + // Restore original values + Object.entries(originalEnv).forEach(([key, value]) => { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + }); + }); +}); +``` + +**Naming Conventions:** +- Files: `kebab-case.ts` +- Functions: `camelCase` +- Types/Interfaces: `PascalCase` +- Constants: `SCREAMING_SNAKE_CASE` for env vars, `camelCase` for others + +**Error Handling:** +- Use try/catch with typed errors +- Log errors with `[Module]` prefix +- Provide actionable error messages + +--- + +## IMPLEMENTATION PLAN + +### Phase 1: Archon Directory Structure (Foundation) + +Introduce unified `~/.archon/` directory structure with centralized path resolution. + +**Directory Structure:** +``` +~/.archon/ # User-level (ARCHON_HOME) +├── workspaces/ # Cloned repositories (via /clone) +│ └── owner/repo/ +├── worktrees/ # Git worktrees for isolation +│ └── repo-name/ +│ └── branch-name/ +└── config.yaml # Global user configuration (non-secrets) + +/.archon/ # Docker container +├── workspaces/ +├── worktrees/ +└── config.yaml + +.archon/ # Per-repo (checked into repo) +├── commands/ # Custom command templates +├── workflows/ # Future: workflow definitions +└── config.yaml # Repo-specific configuration +``` + +**Tasks:** +- Create centralized path resolution module +- Update all path references across codebase +- Update Docker configuration for new paths +- Add backwards compatibility for legacy env vars + +### Phase 2: Docker Distribution + +Publish Docker images to GHCR with multi-arch support. + +**Tasks:** +- Create GitHub Actions publish workflow +- Add OCI labels to Dockerfile +- Create minimal compose file for end users +- Update README with pull instructions + +### Phase 3: YAML Configuration System + +Add YAML-based configuration using Bun native support. + +**Config Precedence Chain:** +1. Environment variables (secrets, highest priority) +2. `~/.archon/config.yaml` (global user preferences) +3. `.archon/config.yaml` (per-repo settings) +4. Built-in defaults (lowest priority) + +**Tasks:** +- Create configuration type definitions +- Implement config loader with precedence +- Integrate with startup and orchestrator +- Add hot-reload support for development + +### Phase 4: Developer Experience + +Improve setup validation and debugging. + +**Tasks:** +- Create setup validation script +- Enhance status command with config info +- Update documentation + +--- + +## STEP-BY-STEP TASKS + +IMPORTANT: Execute every task in order, top to bottom. Each task is atomic and independently testable. + +--- + +### Phase 1: Archon Directory Structure + +--- + +#### Task 1.1: CREATE `src/utils/archon-paths.ts` + +**IMPLEMENT**: Centralized path resolution for all Archon directories + +```typescript +/** + * Archon path resolution utilities + * + * Directory structure: + * ~/.archon/ # User-level (ARCHON_HOME) + * ├── workspaces/ # Cloned repositories + * ├── worktrees/ # Git worktrees + * └── config.yaml # Global config + * + * For Docker: /.archon/ + */ + +import { join } from 'path'; +import { homedir } from 'os'; + +/** + * Expand ~ to home directory + */ +export function expandTilde(path: string): string { + if (path.startsWith('~')) { + const pathAfterTilde = path.slice(1).replace(/^[/\\]/, ''); + return join(homedir(), pathAfterTilde); + } + return path; +} + +/** + * Detect if running in Docker container + */ +export function isDocker(): boolean { + return ( + process.env.WORKSPACE_PATH === '/workspace' || + (process.env.HOME === '/root' && Boolean(process.env.WORKSPACE_PATH)) || + process.env.ARCHON_DOCKER === 'true' + ); +} + +/** + * Get the Archon home directory + * - Docker: /.archon + * - Local: ~/.archon (or ARCHON_HOME env var) + */ +export function getArchonHome(): string { + if (isDocker()) { + return '/.archon'; + } + + const envHome = process.env.ARCHON_HOME; + if (envHome) { + return expandTilde(envHome); + } + + return join(homedir(), '.archon'); +} + +/** + * Get the workspaces directory (where repos are cloned) + * Replaces WORKSPACE_PATH + */ +export function getArchonWorkspacesPath(): string { + // Legacy support: if WORKSPACE_PATH is explicitly set to a custom path, use it + const legacyPath = process.env.WORKSPACE_PATH; + if (legacyPath && legacyPath !== './workspace' && !isDocker()) { + return expandTilde(legacyPath); + } + + return join(getArchonHome(), 'workspaces'); +} + +/** + * Get the worktrees directory (where git worktrees are created) + * Replaces WORKTREE_BASE + */ +export function getArchonWorktreesPath(): string { + // Legacy support: if WORKTREE_BASE is explicitly set, use it + const legacyPath = process.env.WORKTREE_BASE; + if (legacyPath && !isDocker()) { + return expandTilde(legacyPath); + } + + return join(getArchonHome(), 'worktrees'); +} + +/** + * Get the global config file path + */ +export function getArchonConfigPath(): string { + return join(getArchonHome(), 'config.yaml'); +} + +/** + * Get command folder search paths for a repository + * Returns folders in priority order (first match wins) + */ +export function getCommandFolderSearchPaths(): string[] { + return ['.archon/commands', '.claude/commands', '.agents/commands']; +} + +/** + * Get workflow folder search paths for a repository (future) + */ +export function getWorkflowFolderSearchPaths(): string[] { + return ['.archon/workflows', '.claude/workflows', '.agents/workflows']; +} + +/** + * Log the Archon paths configuration (for startup) + */ +export function logArchonPaths(): void { + const home = getArchonHome(); + const workspaces = getArchonWorkspacesPath(); + const worktrees = getArchonWorktreesPath(); + const config = getArchonConfigPath(); + + console.log('[Archon] Paths configured:'); + console.log(` Home: ${home}`); + console.log(` Workspaces: ${workspaces}`); + console.log(` Worktrees: ${worktrees}`); + console.log(` Config: ${config}`); +} +``` + +- **PATTERN**: Mirror `src/utils/git.ts:33-62` for Docker detection and tilde expansion +- **IMPORTS**: `path`, `os` +- **GOTCHA**: Must handle both `/workspace` and `./workspace` legacy paths differently +- **VALIDATE**: `bun run type-check` + +--- + +#### Task 1.2: CREATE `src/utils/archon-paths.test.ts` + +**IMPLEMENT**: Comprehensive tests for path resolution + +```typescript +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import { homedir } from 'os'; +import { join } from 'path'; + +import { + isDocker, + getArchonHome, + getArchonWorkspacesPath, + getArchonWorktreesPath, + getArchonConfigPath, + getCommandFolderSearchPaths, + expandTilde, +} from './archon-paths'; + +describe('archon-paths', () => { + const originalEnv: Record = {}; + const envVars = ['WORKSPACE_PATH', 'WORKTREE_BASE', 'ARCHON_HOME', 'ARCHON_DOCKER', 'HOME']; + + beforeEach(() => { + envVars.forEach(key => { + originalEnv[key] = process.env[key]; + }); + }); + + afterEach(() => { + envVars.forEach(key => { + if (originalEnv[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = originalEnv[key]; + } + }); + }); + + describe('expandTilde', () => { + test('expands ~ to home directory', () => { + expect(expandTilde('~/test')).toBe(join(homedir(), 'test')); + }); + + test('returns path unchanged if no tilde', () => { + expect(expandTilde('/absolute/path')).toBe('/absolute/path'); + }); + }); + + describe('isDocker', () => { + test('returns true when WORKSPACE_PATH is /workspace', () => { + process.env.WORKSPACE_PATH = '/workspace'; + expect(isDocker()).toBe(true); + }); + + test('returns true when HOME=/root and WORKSPACE_PATH set', () => { + process.env.HOME = '/root'; + process.env.WORKSPACE_PATH = '/app/workspace'; + expect(isDocker()).toBe(true); + }); + + test('returns true when ARCHON_DOCKER=true', () => { + delete process.env.WORKSPACE_PATH; + process.env.ARCHON_DOCKER = 'true'; + expect(isDocker()).toBe(true); + }); + + test('returns false for local development', () => { + delete process.env.WORKSPACE_PATH; + delete process.env.ARCHON_DOCKER; + process.env.HOME = homedir(); + expect(isDocker()).toBe(false); + }); + }); + + describe('getArchonHome', () => { + test('returns /.archon in Docker', () => { + process.env.WORKSPACE_PATH = '/workspace'; + expect(getArchonHome()).toBe('/.archon'); + }); + + test('returns ARCHON_HOME when set (local)', () => { + delete process.env.WORKSPACE_PATH; + delete process.env.ARCHON_DOCKER; + process.env.ARCHON_HOME = '/custom/archon'; + expect(getArchonHome()).toBe('/custom/archon'); + }); + + test('expands tilde in ARCHON_HOME', () => { + delete process.env.WORKSPACE_PATH; + delete process.env.ARCHON_DOCKER; + process.env.ARCHON_HOME = '~/my-archon'; + expect(getArchonHome()).toBe(join(homedir(), 'my-archon')); + }); + + test('returns ~/.archon by default (local)', () => { + delete process.env.WORKSPACE_PATH; + delete process.env.ARCHON_HOME; + delete process.env.ARCHON_DOCKER; + expect(getArchonHome()).toBe(join(homedir(), '.archon')); + }); + }); + + describe('getArchonWorkspacesPath', () => { + test('uses legacy WORKSPACE_PATH if explicitly set (non-default)', () => { + delete process.env.ARCHON_HOME; + delete process.env.ARCHON_DOCKER; + process.env.WORKSPACE_PATH = '/my/custom/workspace'; + expect(getArchonWorkspacesPath()).toBe('/my/custom/workspace'); + }); + + test('ignores default ./workspace WORKSPACE_PATH', () => { + delete process.env.ARCHON_HOME; + delete process.env.ARCHON_DOCKER; + process.env.WORKSPACE_PATH = './workspace'; + expect(getArchonWorkspacesPath()).toBe(join(homedir(), '.archon', 'workspaces')); + }); + + test('returns ~/.archon/workspaces by default', () => { + delete process.env.WORKSPACE_PATH; + delete process.env.ARCHON_HOME; + delete process.env.ARCHON_DOCKER; + expect(getArchonWorkspacesPath()).toBe(join(homedir(), '.archon', 'workspaces')); + }); + + test('returns /.archon/workspaces in Docker', () => { + process.env.WORKSPACE_PATH = '/workspace'; + expect(getArchonWorkspacesPath()).toBe('/.archon/workspaces'); + }); + }); + + describe('getArchonWorktreesPath', () => { + test('uses legacy WORKTREE_BASE if set (local only)', () => { + delete process.env.WORKSPACE_PATH; + delete process.env.ARCHON_DOCKER; + process.env.WORKTREE_BASE = '/custom/worktrees'; + expect(getArchonWorktreesPath()).toBe('/custom/worktrees'); + }); + + test('ignores WORKTREE_BASE in Docker', () => { + process.env.WORKSPACE_PATH = '/workspace'; + process.env.WORKTREE_BASE = '/custom/worktrees'; + expect(getArchonWorktreesPath()).toBe('/.archon/worktrees'); + }); + + test('returns ~/.archon/worktrees by default (local)', () => { + delete process.env.WORKSPACE_PATH; + delete process.env.WORKTREE_BASE; + delete process.env.ARCHON_DOCKER; + expect(getArchonWorktreesPath()).toBe(join(homedir(), '.archon', 'worktrees')); + }); + }); + + describe('getCommandFolderSearchPaths', () => { + test('returns folders in priority order', () => { + const paths = getCommandFolderSearchPaths(); + expect(paths).toEqual(['.archon/commands', '.claude/commands', '.agents/commands']); + }); + + test('.archon/commands has highest priority', () => { + const paths = getCommandFolderSearchPaths(); + expect(paths[0]).toBe('.archon/commands'); + }); + }); + + describe('getArchonConfigPath', () => { + test('returns path to config.yaml', () => { + delete process.env.WORKSPACE_PATH; + delete process.env.ARCHON_HOME; + delete process.env.ARCHON_DOCKER; + expect(getArchonConfigPath()).toBe(join(homedir(), '.archon', 'config.yaml')); + }); + }); +}); +``` + +- **PATTERN**: Mirror `src/utils/git.test.ts` environment mocking pattern +- **VALIDATE**: `bun test src/utils/archon-paths.test.ts` + +--- + +#### Task 1.3: UPDATE `src/utils/git.ts` + +**IMPLEMENT**: Replace hardcoded path logic with archon-paths module + +- **ADD** import at top: +```typescript +import { getArchonWorktreesPath } from './archon-paths'; +``` + +- **REPLACE** `getWorktreeBase()` function (lines 28-51): +```typescript +/** + * Get the base directory for worktrees + * Now delegates to archon-paths module for consistency + */ +export function getWorktreeBase(_repoPath: string): string { + return getArchonWorktreesPath(); +} +``` + +- **KEEP** local `expandTilde()` function - it's used by other functions in this file +- **PATTERN**: `src/utils/git.ts:33-51` +- **GOTCHA**: Don't remove `getWorktreeBase()` entirely - it's part of the public API +- **VALIDATE**: `bun test src/utils/git.test.ts` + +--- + +#### Task 1.4: UPDATE `src/utils/path-validation.ts` + +**IMPLEMENT**: Use dynamic path resolution instead of constant + +- **ADD** import at top: +```typescript +import { getArchonWorkspacesPath } from './archon-paths'; +``` + +- **REPLACE** constant with function (line 7): +```typescript +// Before: +const WORKSPACE_ROOT = resolve(process.env.WORKSPACE_PATH ?? '/workspace'); + +// After: +// Lazy evaluation to allow tests to modify env vars +function getWorkspaceRoot(): string { + return resolve(getArchonWorkspacesPath()); +} +``` + +- **UPDATE** `isPathWithinWorkspace()`: +```typescript +export function isPathWithinWorkspace( + targetPath: string, + basePath?: string +): boolean { + const workspaceRoot = getWorkspaceRoot(); + const effectiveBase = basePath ?? workspaceRoot; + const resolvedTarget = resolve(effectiveBase, targetPath); + return resolvedTarget === workspaceRoot || resolvedTarget.startsWith(workspaceRoot + sep); +} +``` + +- **UPDATE** `validateAndResolvePath()`: +```typescript +export function validateAndResolvePath( + targetPath: string, + basePath?: string +): string { + const workspaceRoot = getWorkspaceRoot(); + const effectiveBase = basePath ?? workspaceRoot; + const resolvedPath = resolve(effectiveBase, targetPath); + + if (!isPathWithinWorkspace(resolvedPath)) { + throw new Error(`Path must be within ${workspaceRoot} directory`); + } + + return resolvedPath; +} +``` + +- **PATTERN**: `src/utils/path-validation.ts:1-51` +- **GOTCHA**: Use lazy evaluation (function call) not constant, to allow test env mocking +- **VALIDATE**: `bun test src/utils/path-validation.test.ts` + +--- + +#### Task 1.5: UPDATE `src/handlers/command-handler.ts` + +**IMPLEMENT**: Use centralized path resolution and command folder search + +- **ADD** imports at top: +```typescript +import { getArchonWorkspacesPath, getCommandFolderSearchPaths } from '../utils/archon-paths'; +``` + +- **REPLACE** all `resolve(process.env.WORKSPACE_PATH ?? '/workspace')` with `getArchonWorkspacesPath()`: + - Line 31 in `shortenPath()` + - Line 198 in `setcwd` case + - Line 255 in `clone` case + - Line 442-443 in `command-set` case + - Line 481-483 in `load-commands` case + - Line 547 in `repos` case + - Line 632 in `repo` case + - Line 767 in `repo-remove` case + +- **REPLACE** hardcoded command folder arrays with `getCommandFolderSearchPaths()`: + - Line 287: `for (const folder of ['.claude/commands', '.agents/commands'])` + - Line 387: Same + - Line 722: Same + +```typescript +// Before: +for (const folder of ['.claude/commands', '.agents/commands']) { + +// After: +for (const folder of getCommandFolderSearchPaths()) { +``` + +- **UPDATE** help text (line 118) to mention `.archon/commands`: +```typescript +Note: Commands use relative paths (e.g., .archon/commands) +``` + +- **PATTERN**: `src/handlers/command-handler.ts:287-295` +- **GOTCHA**: Don't change command logic, only path resolution +- **VALIDATE**: `bun test src/handlers/command-handler.test.ts` + +--- + +#### Task 1.6: UPDATE `src/adapters/github.ts` + +**IMPLEMENT**: Use centralized path resolution for repository cloning + +- **ADD** imports: +```typescript +import { getArchonWorkspacesPath, getCommandFolderSearchPaths } from '../utils/archon-paths'; +``` + +- **REPLACE** line 411: +```typescript +// Before: +const canonicalPath = join(resolve(process.env.WORKSPACE_PATH ?? '/workspace'), owner, repo); + +// After: +const canonicalPath = join(getArchonWorkspacesPath(), owner, repo); +``` + +- **UPDATE** `autoLoadCommands()` (around line 362) to use `getCommandFolderSearchPaths()`: +```typescript +const commandFolders = getCommandFolderSearchPaths(); +// Use commandFolders in the loop +``` + +- **PATTERN**: `src/adapters/github.ts:362, 411` +- **VALIDATE**: `bun test src/adapters/github.test.ts` + +--- + +#### Task 1.7: UPDATE `src/index.ts` + +**IMPLEMENT**: Update startup with new path logging and warnings + +- **ADD** import: +```typescript +import { getArchonWorkspacesPath, logArchonPaths } from './utils/archon-paths'; +``` + +- **REPLACE** lines 67-76 with: +```typescript +// Log Archon paths configuration +logArchonPaths(); + +// Warn if workspaces path is inside project directory (legacy config) +const workspacePath = getArchonWorkspacesPath(); +const projectRoot = resolve(__dirname, '..'); +if (workspacePath.startsWith(projectRoot + '/') || workspacePath === projectRoot) { + console.warn(''); + console.warn('[Archon] WARNING: Workspaces path is inside project directory'); + console.warn(' This can cause nested repository issues when working on this repo.'); + console.warn(` Current: ${workspacePath}`); + console.warn(' The new default is: ~/.archon/workspaces'); + console.warn(' To use the new default, remove WORKSPACE_PATH from .env'); + console.warn(''); +} +``` + +- **PATTERN**: `src/index.ts:67-76` +- **VALIDATE**: `bun run dev` - check startup logs + +--- + +#### Task 1.8: UPDATE `Dockerfile` + +**IMPLEMENT**: Update paths for new Archon structure + +- **ADD** after line 30 (after creating appuser): +```dockerfile +# Create Archon directories +RUN mkdir -p /.archon/workspaces /.archon/worktrees \ + && chown -R appuser:appuser /.archon +``` + +- **UPDATE** line 29 to remove /workspace creation: +```dockerfile +# Before: +RUN useradd -m -u 1001 -s /bin/bash appuser \ + && mkdir -p /workspace \ + && chown -R appuser:appuser /app /workspace + +# After: +RUN useradd -m -u 1001 -s /bin/bash appuser \ + && chown -R appuser:appuser /app +``` + +- **UPDATE** git safe.directory config (line 59-60): +```dockerfile +# Before: +RUN git config --global --add safe.directory /workspace && \ + git config --global --add safe.directory '/workspace/*' + +# After: +RUN git config --global --add safe.directory '/.archon/workspaces' && \ + git config --global --add safe.directory '/.archon/workspaces/*' && \ + git config --global --add safe.directory '/.archon/worktrees' && \ + git config --global --add safe.directory '/.archon/worktrees/*' +``` + +- **ADD** OCI labels after FROM line: +```dockerfile +LABEL org.opencontainers.image.source="https://github.com/dynamous-community/remote-coding-agent" +LABEL org.opencontainers.image.description="Control AI coding assistants remotely from Telegram, Slack, Discord, and GitHub" +LABEL org.opencontainers.image.licenses="MIT" +``` + +- **GOTCHA**: Keep `/workspace` as legacy mount point for backwards compatibility +- **VALIDATE**: `docker build -t remote-coding-agent-test .` + +--- + +#### Task 1.9: UPDATE `docker-compose.yml` + +**IMPLEMENT**: Update volume mounts for new paths + +- **UPDATE** app service volumes: +```yaml +# Add after existing volumes +volumes: + - ${WORKSPACE_PATH:-./workspace}:/workspace # Legacy support + - archon_data:/.archon # New Archon directory +``` + +- **ADD** named volume: +```yaml +volumes: + postgres_data: + archon_data: # Persistent Archon data +``` + +- **GOTCHA**: Keep legacy `/workspace` mount for backwards compatibility +- **VALIDATE**: `docker compose config` + +--- + +#### Task 1.10: UPDATE `.env.example` + +**IMPLEMENT**: Document new configuration options + +- **ADD** after existing WORKSPACE_PATH documentation: +```bash +# ============================================ +# Archon Directory Configuration (NEW) +# ============================================ +# All Archon-managed files go in ~/.archon/ by default +# Override with ARCHON_HOME to use a custom location +# ARCHON_HOME=~/.archon + +# Legacy Configuration (still supported) +# WORKSPACE_PATH - Override workspaces location (default: ~/.archon/workspaces) +# WORKTREE_BASE - Override worktrees location (default: ~/.archon/worktrees) + +# For Docker, paths are automatically set to /.archon/ +``` + +- **VALIDATE**: Manual review + +--- + +#### Task 1.11: UPDATE `CLAUDE.md` + +**IMPLEMENT**: Document new directory structure + +- **ADD** new section after "Worktree Symbiosis": +```markdown +### Archon Directory Structure + +All Archon-managed files are organized under a dedicated namespace: + +**User-level (`~/.archon/`):** +``` +~/.archon/ +├── workspaces/ # Cloned repositories (via /clone) +│ └── owner/repo/ +├── worktrees/ # Git worktrees for isolation +│ └── repo-name/ +│ └── branch-name/ +└── config.yaml # Global configuration (non-secrets) +``` + +**Repo-level (`.archon/` in any repository):** +``` +.archon/ +├── commands/ # Custom command templates +├── workflows/ # Future: workflow definitions +└── config.yaml # Repo-specific configuration +``` + +**For Docker:** Paths are automatically set to `/.archon/`. + +**Configuration:** +- `ARCHON_HOME` - Override the base directory (default: `~/.archon`) +- Legacy `WORKSPACE_PATH` and `WORKTREE_BASE` are still supported + +**Command folder detection priority:** +1. `.archon/commands/` (new) +2. `.claude/commands/` (legacy) +3. `.agents/commands/` (legacy) +``` + +- **VALIDATE**: Manual review + +--- + +### Phase 2: Docker Distribution + +--- + +#### Task 2.1: CREATE `.github/workflows/publish.yml` + +**IMPLEMENT**: Docker and npm publishing workflow + +```yaml +name: Publish + +on: + release: + types: [published] + push: + tags: + - 'v*' + workflow_dispatch: + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + docker: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=sha + type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', 'main') }} + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max +``` + +- **PATTERN**: `.github/workflows/test.yml` +- **GOTCHA**: Multi-arch builds require QEMU and Buildx setup +- **VALIDATE**: Check workflow syntax in GitHub Actions tab after push + +--- + +#### Task 2.2: CREATE `deploy/docker-compose.yml` + +**IMPLEMENT**: Minimal compose file for end users + +```yaml +# Remote Coding Agent - Docker Compose for End Users +# +# Usage: +# 1. Copy this file and .env.example to your server +# 2. Rename .env.example to .env and configure +# 3. Run: docker compose up -d +# +# For full documentation, see: +# https://github.com/dynamous-community/remote-coding-agent + +services: + app: + image: ghcr.io/dynamous-community/remote-coding-agent:latest + restart: unless-stopped + env_file: .env + ports: + - "${PORT:-3000}:3000" + volumes: + - archon_data:/.archon + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + + # Uncomment to run PostgreSQL locally + # postgres: + # image: postgres:16-alpine + # restart: unless-stopped + # environment: + # POSTGRES_DB: remote_coding_agent + # POSTGRES_USER: postgres + # POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} + # volumes: + # - postgres_data:/var/lib/postgresql/data + # healthcheck: + # test: ["CMD-SHELL", "pg_isready -U postgres"] + # interval: 10s + # timeout: 5s + # retries: 5 + +volumes: + archon_data: + # postgres_data: +``` + +- **VALIDATE**: `cd deploy && docker compose config` + +--- + +#### Task 2.3: CREATE `deploy/.env.example` + +**IMPLEMENT**: Minimal environment example for end users + +```bash +# Remote Coding Agent - Environment Configuration +# Copy to .env and fill in your values + +# ============================================ +# Required: Database +# ============================================ +# Use a managed PostgreSQL (Supabase, Neon, etc.) +DATABASE_URL=postgresql://user:password@host:5432/dbname + +# Or uncomment postgres service in docker-compose.yml and use: +# DATABASE_URL=postgresql://postgres:postgres@postgres:5432/remote_coding_agent + +# ============================================ +# Required: AI Assistant (at least one) +# ============================================ +# Claude (recommended) - Get token: claude setup-token +CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-... + +# Or Codex - Get from ~/.codex/auth.json after: codex login +# CODEX_ID_TOKEN=... +# CODEX_ACCESS_TOKEN=... +# CODEX_REFRESH_TOKEN=... +# CODEX_ACCOUNT_ID=... + +# ============================================ +# Required: Platform (at least one) +# ============================================ +# Telegram - Create bot via @BotFather +TELEGRAM_BOT_TOKEN=123456789:ABC... + +# Discord - Create bot at discord.com/developers +# DISCORD_BOT_TOKEN=... + +# Slack - Create app at api.slack.com/apps +# SLACK_BOT_TOKEN=xoxb-... +# SLACK_APP_TOKEN=xapp-... + +# GitHub Webhooks +# GH_TOKEN=ghp_... +# GITHUB_TOKEN=ghp_... +# WEBHOOK_SECRET=... + +# ============================================ +# Optional +# ============================================ +PORT=3000 +# TELEGRAM_STREAMING_MODE=stream +# DISCORD_STREAMING_MODE=batch +``` + +- **VALIDATE**: Manual review + +--- + +#### Task 2.4: UPDATE `README.md` + +**IMPLEMENT**: Add Docker pull instructions + +- **ADD** new section after "Prerequisites": + +```markdown +--- + +## Quick Start with Docker + +The fastest way to get started: + +```bash +# Pull the latest image +docker pull ghcr.io/dynamous-community/remote-coding-agent:latest + +# Create configuration +mkdir remote-agent && cd remote-agent +curl -fsSL https://raw.githubusercontent.com/dynamous-community/remote-coding-agent/main/deploy/.env.example -o .env +curl -fsSL https://raw.githubusercontent.com/dynamous-community/remote-coding-agent/main/deploy/docker-compose.yml -o docker-compose.yml + +# Edit .env with your tokens +nano .env + +# Start +docker compose up -d + +# Check logs +docker compose logs -f app +``` + +For detailed setup including platform configuration, continue to [Setup Guide](#setup-guide). + +--- +``` + +- **VALIDATE**: Manual review + +--- + +### Phase 3: YAML Configuration System + +--- + +#### Task 3.1: CREATE `src/config/types.ts` + +**IMPLEMENT**: Configuration type definitions + +```typescript +/** + * Configuration type definitions for Archon + * + * Config precedence: + * 1. Environment variables (secrets, highest priority) + * 2. ~/.archon/config.yaml (global user preferences) + * 3. .archon/config.yaml (per-repo settings) + * 4. Built-in defaults (lowest priority) + */ + +/** + * Platform-specific configuration + */ +export interface PlatformConfig { + streaming?: 'stream' | 'batch'; + enabled?: boolean; +} + +export interface TelegramConfig extends PlatformConfig { + // Future: additional Telegram-specific options +} + +export interface DiscordConfig extends PlatformConfig { + createThreads?: boolean; +} + +export interface SlackConfig extends PlatformConfig { + // Future: additional Slack-specific options +} + +export interface GitHubConfig extends PlatformConfig { + autoClone?: boolean; + autoLoadCommands?: boolean; +} + +/** + * AI assistant configuration + */ +export interface AIConfig { + default?: 'claude' | 'codex'; + claude?: { + model?: string; + }; + codex?: { + model?: string; + }; +} + +/** + * Path configuration (usually from env vars) + */ +export interface PathConfig { + home?: string; + workspaces?: string; + worktrees?: string; +} + +/** + * Workflow configuration (future) + */ +export interface WorkflowConfig { + enabled?: boolean; + autoRun?: boolean; +} + +/** + * Complete Archon configuration + */ +export interface ArchonConfig { + // Paths (usually resolved from env/defaults, can be overridden) + paths?: PathConfig; + + // AI assistant preferences + ai?: AIConfig; + + // Platform-specific settings + platforms?: { + telegram?: TelegramConfig; + discord?: DiscordConfig; + slack?: SlackConfig; + github?: GitHubConfig; + }; + + // Workflow engine settings (future) + workflows?: WorkflowConfig; + + // Default behaviors + defaults?: { + streaming?: 'stream' | 'batch'; + aiAssistant?: 'claude' | 'codex'; + }; +} + +/** + * Default configuration values + */ +export const DEFAULT_CONFIG: ArchonConfig = { + ai: { + default: 'claude', + }, + platforms: { + telegram: { streaming: 'stream' }, + discord: { streaming: 'batch', createThreads: true }, + slack: { streaming: 'batch' }, + github: { streaming: 'batch', autoClone: true, autoLoadCommands: true }, + }, + defaults: { + streaming: 'batch', + aiAssistant: 'claude', + }, +}; +``` + +- **PATTERN**: `src/types/index.ts` +- **VALIDATE**: `bun run type-check` + +--- + +#### Task 3.2: CREATE `src/config/loader.ts` + +**IMPLEMENT**: YAML configuration loading with precedence chain + +```typescript +/** + * Configuration loader with precedence chain + * + * Precedence (highest to lowest): + * 1. Environment variables (for secrets) + * 2. ~/.archon/config.yaml (global user preferences) + * 3. .archon/config.yaml (per-repo settings) + * 4. Built-in defaults + */ + +import { existsSync } from 'fs'; +import { join } from 'path'; +import { getArchonHome, getArchonConfigPath } from '../utils/archon-paths'; +import { ArchonConfig, DEFAULT_CONFIG } from './types'; + +// Cache for loaded configs +let globalConfigCache: ArchonConfig | null = null; +let globalConfigMtime: number = 0; + +/** + * Deep merge two objects, with source taking precedence + */ +function deepMerge>(target: T, source: Partial): T { + const result = { ...target }; + + for (const key of Object.keys(source) as (keyof T)[]) { + const sourceValue = source[key]; + const targetValue = target[key]; + + if ( + sourceValue !== null && + typeof sourceValue === 'object' && + !Array.isArray(sourceValue) && + targetValue !== null && + typeof targetValue === 'object' && + !Array.isArray(targetValue) + ) { + result[key] = deepMerge( + targetValue as Record, + sourceValue as Record + ) as T[keyof T]; + } else if (sourceValue !== undefined) { + result[key] = sourceValue as T[keyof T]; + } + } + + return result; +} + +/** + * Load and parse a YAML config file + */ +async function loadYamlFile(filePath: string): Promise { + try { + if (!existsSync(filePath)) { + return null; + } + + const content = await Bun.file(filePath).text(); + const parsed = Bun.YAML.parse(content) as ArchonConfig; + return parsed; + } catch (error) { + console.warn(`[Config] Failed to load ${filePath}:`, error); + return null; + } +} + +/** + * Load global config from ~/.archon/config.yaml + * Uses caching with mtime check for hot reload support + */ +export async function loadGlobalConfig(): Promise { + const configPath = getArchonConfigPath(); + + try { + if (existsSync(configPath)) { + const stat = await Bun.file(configPath).stat(); + const mtime = stat?.mtime?.getTime() ?? 0; + + // Return cached if file hasn't changed + if (globalConfigCache && mtime === globalConfigMtime) { + return globalConfigCache; + } + + const loaded = await loadYamlFile(configPath); + if (loaded) { + globalConfigCache = deepMerge(DEFAULT_CONFIG, loaded); + globalConfigMtime = mtime; + return globalConfigCache; + } + } + } catch { + // Ignore errors, return defaults + } + + return DEFAULT_CONFIG; +} + +/** + * Load repo-specific config from .archon/config.yaml + */ +export async function loadRepoConfig(repoPath: string): Promise { + const configPath = join(repoPath, '.archon', 'config.yaml'); + return loadYamlFile(configPath); +} + +/** + * Load merged config for a specific repo + * Merges: defaults < global < repo + */ +export async function loadConfig(repoPath?: string): Promise { + // Start with defaults + let config = { ...DEFAULT_CONFIG }; + + // Merge global config + const globalConfig = await loadGlobalConfig(); + config = deepMerge(config, globalConfig); + + // Merge repo config if provided + if (repoPath) { + const repoConfig = await loadRepoConfig(repoPath); + if (repoConfig) { + config = deepMerge(config, repoConfig); + } + } + + return config; +} + +/** + * Get a specific config value with type safety + */ +export function getConfigValue( + config: ArchonConfig, + path: string, + defaultValue: T +): T { + const parts = path.split('.'); + let current: unknown = config; + + for (const part of parts) { + if (current === null || current === undefined || typeof current !== 'object') { + return defaultValue; + } + current = (current as Record)[part]; + } + + return (current as T) ?? defaultValue; +} + +/** + * Clear config cache (useful for testing) + */ +export function clearConfigCache(): void { + globalConfigCache = null; + globalConfigMtime = 0; +} + +/** + * Log loaded configuration (for debugging) + */ +export function logConfig(config: ArchonConfig): void { + console.log('[Config] Loaded configuration:'); + console.log(` AI Default: ${config.ai?.default ?? 'claude'}`); + console.log(` Telegram Streaming: ${config.platforms?.telegram?.streaming ?? 'stream'}`); + console.log(` Discord Streaming: ${config.platforms?.discord?.streaming ?? 'batch'}`); + console.log(` Slack Streaming: ${config.platforms?.slack?.streaming ?? 'batch'}`); + console.log(` GitHub Streaming: ${config.platforms?.github?.streaming ?? 'batch'}`); +} +``` + +- **PATTERN**: Uses Bun native YAML parsing +- **IMPORTS**: Bun built-in YAML +- **GOTCHA**: Cache invalidation based on mtime for hot reload +- **VALIDATE**: `bun run type-check` + +--- + +#### Task 3.3: CREATE `src/config/loader.test.ts` + +**IMPLEMENT**: Tests for configuration loading + +```typescript +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import { join } from 'path'; +import { mkdirSync, writeFileSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; + +import { + loadConfig, + loadGlobalConfig, + loadRepoConfig, + getConfigValue, + clearConfigCache, +} from './loader'; +import { DEFAULT_CONFIG } from './types'; + +describe('config/loader', () => { + const testDir = join(tmpdir(), 'archon-config-test-' + Date.now()); + const originalEnv: Record = {}; + + beforeEach(() => { + // Save env + originalEnv.ARCHON_HOME = process.env.ARCHON_HOME; + + // Create test directory + mkdirSync(testDir, { recursive: true }); + process.env.ARCHON_HOME = testDir; + + // Clear cache + clearConfigCache(); + }); + + afterEach(() => { + // Restore env + if (originalEnv.ARCHON_HOME === undefined) { + delete process.env.ARCHON_HOME; + } else { + process.env.ARCHON_HOME = originalEnv.ARCHON_HOME; + } + + // Cleanup + try { + rmSync(testDir, { recursive: true, force: true }); + } catch { + // Ignore + } + + clearConfigCache(); + }); + + describe('loadGlobalConfig', () => { + test('returns defaults when no config file', async () => { + const config = await loadGlobalConfig(); + expect(config.ai?.default).toBe('claude'); + }); + + test('loads and merges config file', async () => { + const configPath = join(testDir, 'config.yaml'); + writeFileSync(configPath, ` +ai: + default: codex +platforms: + telegram: + streaming: batch +`); + + const config = await loadGlobalConfig(); + expect(config.ai?.default).toBe('codex'); + expect(config.platforms?.telegram?.streaming).toBe('batch'); + // Defaults should still be present + expect(config.platforms?.discord?.streaming).toBe('batch'); + }); + }); + + describe('loadRepoConfig', () => { + test('returns null when no config file', async () => { + const config = await loadRepoConfig('/nonexistent'); + expect(config).toBeNull(); + }); + + test('loads repo config', async () => { + const repoPath = join(testDir, 'test-repo'); + const archonDir = join(repoPath, '.archon'); + mkdirSync(archonDir, { recursive: true }); + writeFileSync(join(archonDir, 'config.yaml'), ` +ai: + default: codex +`); + + const config = await loadRepoConfig(repoPath); + expect(config?.ai?.default).toBe('codex'); + }); + }); + + describe('loadConfig', () => { + test('merges global and repo configs', async () => { + // Create global config + writeFileSync(join(testDir, 'config.yaml'), ` +ai: + default: claude +platforms: + telegram: + streaming: stream +`); + + // Create repo config + const repoPath = join(testDir, 'test-repo'); + const archonDir = join(repoPath, '.archon'); + mkdirSync(archonDir, { recursive: true }); + writeFileSync(join(archonDir, 'config.yaml'), ` +platforms: + telegram: + streaming: batch +`); + + const config = await loadConfig(repoPath); + + // Global value preserved + expect(config.ai?.default).toBe('claude'); + // Repo value overrides global + expect(config.platforms?.telegram?.streaming).toBe('batch'); + }); + }); + + describe('getConfigValue', () => { + test('gets nested values', () => { + const value = getConfigValue(DEFAULT_CONFIG, 'platforms.telegram.streaming', 'unknown'); + expect(value).toBe('stream'); + }); + + test('returns default for missing paths', () => { + const value = getConfigValue(DEFAULT_CONFIG, 'nonexistent.path', 'fallback'); + expect(value).toBe('fallback'); + }); + }); +}); +``` + +- **PATTERN**: `src/utils/archon-paths.test.ts` for env mocking +- **VALIDATE**: `bun test src/config/loader.test.ts` + +--- + +#### Task 3.4: CREATE `src/config/index.ts` + +**IMPLEMENT**: Re-export configuration module + +```typescript +/** + * Configuration module exports + */ +export * from './types'; +export * from './loader'; +``` + +- **VALIDATE**: `bun run type-check` + +--- + +#### Task 3.5: UPDATE `src/index.ts` - Config Integration + +**IMPLEMENT**: Load and log configuration at startup + +- **ADD** import: +```typescript +import { loadGlobalConfig, logConfig } from './config'; +``` + +- **ADD** after `logArchonPaths()`: +```typescript +// Load and log configuration +const config = await loadGlobalConfig(); +logConfig(config); +``` + +- **VALIDATE**: `bun run dev` - check startup logs + +--- + +### Phase 4: Developer Experience + +--- + +#### Task 4.1: CREATE `scripts/validate-setup.sh` + +**IMPLEMENT**: Setup validation script + +```bash +#!/bin/bash +# validate-setup.sh - Validate Remote Coding Agent configuration +# +# Usage: ./scripts/validate-setup.sh + +set -e + +echo "🔍 Remote Coding Agent Setup Validator" +echo "=======================================" +echo "" + +ERRORS=0 +WARNINGS=0 + +# Color codes +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +check_pass() { + echo -e "${GREEN}✓${NC} $1" +} + +check_fail() { + echo -e "${RED}✗${NC} $1" + ((ERRORS++)) +} + +check_warn() { + echo -e "${YELLOW}⚠${NC} $1" + ((WARNINGS++)) +} + +# Check .env file +echo "📋 Configuration Files" +echo "----------------------" + +if [ -f ".env" ]; then + check_pass ".env file exists" +else + check_fail ".env file not found (copy from .env.example)" +fi + +# Check required environment variables +echo "" +echo "🔑 Required Environment Variables" +echo "----------------------------------" + +# Load .env if exists +if [ -f ".env" ]; then + set -a + source .env 2>/dev/null || true + set +a +fi + +if [ -n "$DATABASE_URL" ]; then + check_pass "DATABASE_URL is set" +else + check_fail "DATABASE_URL not set" +fi + +# AI Assistants +echo "" +echo "🤖 AI Assistants" +echo "----------------" + +if [ -n "$CLAUDE_CODE_OAUTH_TOKEN" ] || [ -n "$CLAUDE_API_KEY" ]; then + check_pass "Claude credentials configured" +else + check_warn "Claude credentials not found" +fi + +if [ -n "$CODEX_ID_TOKEN" ] && [ -n "$CODEX_ACCESS_TOKEN" ]; then + check_pass "Codex credentials configured" +else + check_warn "Codex credentials not found" +fi + +if [ -z "$CLAUDE_CODE_OAUTH_TOKEN" ] && [ -z "$CLAUDE_API_KEY" ] && [ -z "$CODEX_ID_TOKEN" ]; then + check_fail "No AI assistant credentials found (need at least one)" +fi + +# Platforms +echo "" +echo "💬 Platform Adapters" +echo "--------------------" + +PLATFORMS=0 + +if [ -n "$TELEGRAM_BOT_TOKEN" ]; then + check_pass "Telegram configured" + ((PLATFORMS++)) +else + check_warn "Telegram not configured" +fi + +if [ -n "$DISCORD_BOT_TOKEN" ]; then + check_pass "Discord configured" + ((PLATFORMS++)) +else + check_warn "Discord not configured" +fi + +if [ -n "$SLACK_BOT_TOKEN" ] && [ -n "$SLACK_APP_TOKEN" ]; then + check_pass "Slack configured" + ((PLATFORMS++)) +else + check_warn "Slack not configured" +fi + +if [ -n "$GITHUB_TOKEN" ] && [ -n "$WEBHOOK_SECRET" ]; then + check_pass "GitHub webhooks configured" + ((PLATFORMS++)) +else + check_warn "GitHub webhooks not configured" +fi + +if [ $PLATFORMS -eq 0 ]; then + check_fail "No platform adapters configured (need at least one)" +fi + +# Docker +echo "" +echo "🐳 Docker" +echo "---------" + +if command -v docker &> /dev/null; then + check_pass "Docker is installed" + + if docker compose version &> /dev/null; then + check_pass "Docker Compose is available" + else + check_warn "Docker Compose not found" + fi +else + check_warn "Docker not installed (required for containerized deployment)" +fi + +# Archon paths +echo "" +echo "📁 Archon Paths" +echo "---------------" + +ARCHON_HOME="${ARCHON_HOME:-$HOME/.archon}" +echo " Home: $ARCHON_HOME" +echo " Workspaces: $ARCHON_HOME/workspaces" +echo " Worktrees: $ARCHON_HOME/worktrees" + +if [ -d "$ARCHON_HOME" ]; then + check_pass "Archon home directory exists" +else + check_warn "Archon home directory will be created on first run" +fi + +# Summary +echo "" +echo "=======================================" +if [ $ERRORS -gt 0 ]; then + echo -e "${RED}❌ Validation failed with $ERRORS error(s) and $WARNINGS warning(s)${NC}" + echo "" + echo "Please fix the errors above before running the application." + exit 1 +elif [ $WARNINGS -gt 0 ]; then + echo -e "${YELLOW}⚠️ Validation passed with $WARNINGS warning(s)${NC}" + echo "" + echo "The application should work, but some features may be unavailable." + exit 0 +else + echo -e "${GREEN}✅ All checks passed!${NC}" + echo "" + echo "You can start the application with:" + echo " bun run dev # Development with hot reload" + echo " docker compose up -d # Docker deployment" + exit 0 +fi +``` + +- **VALIDATE**: `chmod +x scripts/validate-setup.sh && ./scripts/validate-setup.sh` + +--- + +#### Task 4.2: UPDATE `package.json` + +**IMPLEMENT**: Add validate script + +- **ADD** to scripts: +```json +"validate": "./scripts/validate-setup.sh" +``` + +- **VALIDATE**: `bun run validate` + +--- + +### Phase 5: Documentation + +--- + +#### Task 5.1: UPDATE `README.md` - Comprehensive Setup Guide + +**IMPLEMENT**: Restructure README with clear setup paths for different users + +The README should have these sections in order: + +1. **Quick Start with Docker** (for users who want to try it fast) +2. **Local Development Setup** (for developers) +3. **Self-Hosted Deployment** (link to docs/cloud-deployment.md) +4. **Configuration Reference** (link to docs/configuration.md) + +- **ADD** after the project description: +```markdown +## Quick Start + +### Option 1: Docker (Recommended for trying it out) + +```bash +# 1. Get the files +mkdir remote-agent && cd remote-agent +curl -fsSL https://raw.githubusercontent.com/dynamous-community/remote-coding-agent/main/deploy/docker-compose.yml -o docker-compose.yml +curl -fsSL https://raw.githubusercontent.com/dynamous-community/remote-coding-agent/main/deploy/.env.example -o .env + +# 2. Configure (edit .env with your tokens) +nano .env + +# 3. Run +docker compose up -d + +# 4. Check it's working +curl http://localhost:3000/health +``` + +### Option 2: Local Development + +```bash +# 1. Clone and install +git clone https://github.com/dynamous-community/remote-coding-agent +cd remote-coding-agent +bun install + +# 2. Configure +cp .env.example .env +nano .env # Add your tokens + +# 3. Start database +docker compose --profile with-db up -d postgres + +# 4. Run migrations +bun run migrate + +# 5. Start with hot reload +bun run dev + +# 6. Validate setup +bun run validate +``` + +### Option 3: Self-Hosted Production + +See [Cloud Deployment Guide](docs/cloud-deployment.md) for deploying to: +- DigitalOcean, Linode, AWS EC2, or any VPS +- With automatic HTTPS via Caddy + +## Directory Structure + +The app uses `~/.archon/` for all managed files: + +``` +~/.archon/ +├── workspaces/ # Cloned repositories +├── worktrees/ # Git worktrees for isolation +└── config.yaml # Optional: global configuration +``` + +On Windows: `C:\Users\\.archon\` +In Docker: `/.archon/` + +See [Configuration Guide](docs/configuration.md) for customization options. +``` + +- **VALIDATE**: Manual review, links work + +--- + +#### Task 5.2: CREATE `docs/configuration.md` + +**IMPLEMENT**: Comprehensive configuration reference + +```markdown +# Configuration Guide + +This guide covers all configuration options for the Remote Coding Agent. + +## Configuration Methods + +Configuration can be set via three methods (in order of precedence): + +1. **Environment Variables** (highest priority) - For secrets and deployment-specific settings +2. **Global Config** (`~/.archon/config.yaml`) - For user preferences +3. **Repo Config** (`.archon/config.yaml` in each repo) - For project-specific settings + +## Environment Variables + +### Required + +| Variable | Description | Example | +|----------|-------------|---------| +| `DATABASE_URL` | PostgreSQL connection string | `postgresql://user:pass@host:5432/db` | + +### AI Assistants (at least one required) + +| Variable | Description | Example | +|----------|-------------|---------| +| `CLAUDE_CODE_OAUTH_TOKEN` | Claude OAuth token | `sk-ant-oat01-...` | +| `CLAUDE_API_KEY` | Claude API key (alternative) | `sk-ant-...` | +| `CODEX_ID_TOKEN` | Codex ID token | `eyJ...` | +| `CODEX_ACCESS_TOKEN` | Codex access token | `eyJ...` | +| `CODEX_REFRESH_TOKEN` | Codex refresh token | `rt_...` | +| `CODEX_ACCOUNT_ID` | Codex account ID | `uuid` | + +### Platform Adapters (at least one required) + +| Variable | Description | Example | +|----------|-------------|---------| +| `TELEGRAM_BOT_TOKEN` | Telegram bot token | `123456789:ABC...` | +| `DISCORD_BOT_TOKEN` | Discord bot token | `...` | +| `SLACK_BOT_TOKEN` | Slack bot token | `xoxb-...` | +| `SLACK_APP_TOKEN` | Slack app token | `xapp-...` | +| `GITHUB_TOKEN` | GitHub personal access token | `ghp_...` | +| `GH_TOKEN` | Same as GITHUB_TOKEN | `ghp_...` | +| `WEBHOOK_SECRET` | GitHub webhook secret | `random-string` | + +### Optional + +| Variable | Description | Default | +|----------|-------------|---------| +| `PORT` | HTTP server port | `3000` | +| `ARCHON_HOME` | Override Archon directory | `~/.archon` | +| `WORKSPACE_PATH` | Legacy: override workspaces path | `~/.archon/workspaces` | +| `WORKTREE_BASE` | Legacy: override worktrees path | `~/.archon/worktrees` | +| `DEFAULT_AI_ASSISTANT` | Default AI assistant | `claude` | +| `TELEGRAM_STREAMING_MODE` | Telegram streaming mode | `stream` | +| `DISCORD_STREAMING_MODE` | Discord streaming mode | `batch` | +| `SLACK_STREAMING_MODE` | Slack streaming mode | `batch` | +| `GITHUB_STREAMING_MODE` | GitHub streaming mode | `batch` | + +## YAML Configuration + +For non-secret settings, you can use YAML configuration files. + +### Global Config: `~/.archon/config.yaml` + +```yaml +# AI assistant preferences +ai: + default: claude # or 'codex' + +# Platform-specific settings +platforms: + telegram: + streaming: stream # or 'batch' + discord: + streaming: batch + createThreads: true + slack: + streaming: batch + github: + streaming: batch + autoClone: true + autoLoadCommands: true + +# Default behaviors +defaults: + streaming: batch + aiAssistant: claude +``` + +### Repo Config: `.archon/config.yaml` + +Place this in any repository to override settings for that repo: + +```yaml +# Override AI assistant for this repo +ai: + default: codex + +# Repo-specific platform settings +platforms: + github: + autoLoadCommands: false +``` + +## Directory Structure + +### Archon Home (`~/.archon/`) + +| Path | Purpose | +|------|---------| +| `~/.archon/workspaces/` | Cloned repositories | +| `~/.archon/worktrees/` | Git worktrees for isolation | +| `~/.archon/config.yaml` | Global configuration | + +### Per-Repo (`.archon/`) + +| Path | Purpose | +|------|---------| +| `.archon/commands/` | Custom command templates | +| `.archon/workflows/` | Future: workflow definitions | +| `.archon/config.yaml` | Repo-specific configuration | + +### Platform-Specific Paths + +| Platform | Default Path | +|----------|--------------| +| macOS | `~/.archon/` → `/Users//.archon/` | +| Linux | `~/.archon/` → `/home//.archon/` | +| Windows | `~/.archon/` → `C:\Users\\.archon\` | +| Docker | `/.archon/` (fixed) | + +### Custom Paths + +Override with environment variables: + +```bash +# Change entire Archon home +export ARCHON_HOME=/custom/path + +# Or change specific directories (legacy support) +export WORKSPACE_PATH=/custom/workspaces +export WORKTREE_BASE=/custom/worktrees +``` + +## Setup Validation + +Run the validation script to check your configuration: + +```bash +# From repo root +./scripts/validate-setup.sh + +# Or via bun +bun run validate +``` + +This checks: +- Required environment variables +- AI assistant credentials +- Platform adapter configuration +- Directory permissions +- Docker availability +``` + +- **VALIDATE**: Manual review + +--- + +#### Task 5.3: UPDATE `docs/cloud-deployment.md` + +**IMPLEMENT**: Update cloud deployment guide for new paths + +- **UPDATE** Section 4 (Environment Configuration) to reference new paths: +```markdown +### Archon Directory + +The app stores cloned repositories and worktrees in `/.archon/` inside the container. + +This is automatically configured. If you need to persist data between container restarts, the `docker-compose.yml` mounts a volume to `/.archon/`. + +For custom paths, set `ARCHON_HOME` in your `.env`: +```env +# Default (recommended) +# ARCHON_HOME=/.archon + +# Custom location +ARCHON_HOME=/data/archon +``` +``` + +- **UPDATE** Section 7 (Start Services) to use new docker-compose: +```markdown +### Using Pre-built Images (Recommended) + +```bash +# Download deploy files +curl -fsSL https://raw.githubusercontent.com/dynamous-community/remote-coding-agent/main/deploy/docker-compose.yml -o docker-compose.yml + +# Start (pulls from GHCR automatically) +docker compose up -d +``` + +### Building Locally + +```bash +# Clone and build +git clone https://github.com/dynamous-community/remote-coding-agent +cd remote-coding-agent +docker compose -f docker-compose.yml -f docker-compose.cloud.yml up -d --build +``` +``` + +- **ADD** troubleshooting section for Archon paths: +```markdown +### Archon Directory Issues + +**Check directory exists:** +```bash +docker compose exec app ls -la /.archon/ +``` + +**Check permissions:** +```bash +docker compose exec app id +# Should show uid=1001(appuser) +``` + +**Manual directory creation (if needed):** +```bash +docker compose exec app mkdir -p /.archon/workspaces /.archon/worktrees +``` +``` + +- **VALIDATE**: Manual review + +--- + +#### Task 5.4: CREATE `docs/getting-started.md` + +**IMPLEMENT**: Step-by-step getting started guide for new users + +```markdown +# Getting Started + +This guide walks you through setting up the Remote Coding Agent from scratch. + +## Prerequisites + +Before you begin, you'll need: + +1. **Docker** (recommended) or **Bun** runtime +2. **PostgreSQL** database (local or managed like Supabase/Neon) +3. **AI Assistant credentials** (Claude or Codex) +4. **Platform credentials** (Telegram, Discord, Slack, or GitHub) + +## Step 1: Choose Your Setup Method + +| Method | Best For | Time | +|--------|----------|------| +| [Docker Quick Start](#docker-quick-start) | Trying it out, production | ~10 min | +| [Local Development](#local-development) | Contributing, customizing | ~15 min | +| [Cloud Deployment](cloud-deployment.md) | 24/7 self-hosted | ~30 min | + +## Docker Quick Start + +### 1.1 Get the Files + +```bash +mkdir remote-agent && cd remote-agent + +# Download docker-compose and env template +curl -fsSL https://raw.githubusercontent.com/dynamous-community/remote-coding-agent/main/deploy/docker-compose.yml -o docker-compose.yml +curl -fsSL https://raw.githubusercontent.com/dynamous-community/remote-coding-agent/main/deploy/.env.example -o .env +``` + +### 1.2 Get Your Credentials + +#### Database + +**Option A: Use a managed database (recommended)** +1. Create a free database at [Supabase](https://supabase.com) or [Neon](https://neon.tech) +2. Copy the connection string + +**Option B: Run PostgreSQL locally** +- Uncomment the postgres service in docker-compose.yml +- Use: `postgresql://postgres:postgres@postgres:5432/remote_coding_agent` + +#### AI Assistant + +**Claude (recommended):** +1. Install Claude Code CLI: https://docs.anthropic.com/claude-code +2. Run: `claude setup-token` +3. Copy the token (starts with `sk-ant-oat01-`) + +**Codex:** +1. Run: `codex login` +2. Copy credentials from `~/.codex/auth.json` + +#### Platform (choose at least one) + +**Telegram:** +1. Message [@BotFather](https://t.me/BotFather) on Telegram +2. Send `/newbot` and follow prompts +3. Copy the bot token + +**Discord:** +1. Go to [Discord Developer Portal](https://discord.com/developers/applications) +2. Create New Application → Bot → Reset Token +3. Enable MESSAGE CONTENT INTENT in Bot settings +4. Copy the bot token + +**Slack:** +1. Go to [Slack API](https://api.slack.com/apps) +2. Create New App → From Scratch +3. See [Slack Setup Guide](slack-setup.md) for detailed steps + +### 1.3 Configure + +Edit `.env` with your credentials: + +```bash +nano .env +``` + +At minimum, set: +- `DATABASE_URL` +- One AI assistant (`CLAUDE_CODE_OAUTH_TOKEN` or Codex credentials) +- One platform (`TELEGRAM_BOT_TOKEN`, `DISCORD_BOT_TOKEN`, etc.) + +### 1.4 Start + +```bash +docker compose up -d +``` + +### 1.5 Verify + +```bash +# Check health +curl http://localhost:3000/health +# Expected: {"status":"ok"} + +# Check database +curl http://localhost:3000/health/db +# Expected: {"status":"ok","database":"connected"} +``` + +### 1.6 Test Your Bot + +Send a message to your bot: +- **Telegram**: Message your bot with `/help` +- **Discord**: Mention your bot with `@botname /help` +- **Slack**: Message your bot with `/help` + +## Local Development + +### 2.1 Clone and Install + +```bash +git clone https://github.com/dynamous-community/remote-coding-agent +cd remote-coding-agent +bun install +``` + +### 2.2 Configure + +```bash +cp .env.example .env +nano .env # Add your credentials (same as Docker method) +``` + +### 2.3 Start Database + +```bash +docker compose --profile with-db up -d postgres +``` + +### 2.4 Run Migrations + +```bash +psql $DATABASE_URL < migrations/000_combined.sql +``` + +### 2.5 Validate Setup + +```bash +./scripts/validate-setup.sh +``` + +### 2.6 Start Development Server + +```bash +bun run dev +``` + +The server starts with hot reload. Changes to code automatically restart. + +## Next Steps + +- [Configuration Guide](configuration.md) - Customize settings +- [Command System](../CLAUDE.md#command-system-patterns) - Create custom commands +- [Cloud Deployment](cloud-deployment.md) - Deploy for 24/7 operation + +## Troubleshooting + +### "Database connection failed" + +1. Check `DATABASE_URL` is correct +2. For managed DB: Ensure IP is whitelisted +3. For local: Ensure postgres container is running: `docker compose ps` + +### "No AI assistant credentials found" + +Set at least one of: +- `CLAUDE_CODE_OAUTH_TOKEN` (recommended) +- `CLAUDE_API_KEY` +- `CODEX_ID_TOKEN` + `CODEX_ACCESS_TOKEN` + `CODEX_REFRESH_TOKEN` + +### "Bot not responding" + +1. Check logs: `docker compose logs -f app` or terminal output for `bun run dev` +2. Verify bot token is correct +3. For Discord: Ensure MESSAGE CONTENT INTENT is enabled +4. For Slack: Ensure Socket Mode is enabled + +### Archon Directory Not Created + +The `~/.archon/` directory is created automatically on first use. To create manually: + +```bash +mkdir -p ~/.archon/workspaces ~/.archon/worktrees +``` +``` + +- **VALIDATE**: Manual review + +--- + +#### Task 5.5: CREATE `docs/archon-architecture.md` + +**IMPLEMENT**: Technical documentation explaining the Archon directory architecture for developers + +```markdown +# Archon Architecture + +This document explains the Archon directory structure and configuration system for developers contributing to or extending the remote-coding-agent. + +## Overview + +Archon is the unified directory and configuration system for the remote-coding-agent. It provides: + +1. **Consistent paths** across all platforms (Mac, Linux, Windows, Docker) +2. **Configuration precedence** chain (env → global → repo → defaults) +3. **Future-ready structure** for workflow engine and UI integration + +## Directory Structure + +### User-Level: `~/.archon/` + +``` +~/.archon/ # ARCHON_HOME +├── workspaces/ # Cloned repositories +│ └── owner/ +│ └── repo/ +├── worktrees/ # Git worktrees for isolation +│ └── repo-name/ +│ └── branch-name/ +└── config.yaml # Global user configuration +``` + +**Purpose:** +- `workspaces/` - Repositories cloned via `/clone` command or GitHub adapter +- `worktrees/` - Isolated git worktrees created per conversation/issue/PR +- `config.yaml` - Non-secret user preferences + +### Repo-Level: `.archon/` + +``` +any-repo/.archon/ +├── commands/ # Custom command templates +│ ├── plan.md +│ └── execute.md +├── workflows/ # Future: workflow definitions +│ └── pr-review.yaml +└── config.yaml # Repo-specific configuration +``` + +**Purpose:** +- `commands/` - Slash command templates (replaces `.claude/commands/`, `.agents/commands/`) +- `workflows/` - Future workflow engine definitions +- `config.yaml` - Project-specific settings + +### Docker: `/.archon/` + +In Docker containers, the Archon home is fixed at `/.archon/` (root level). This is: +- Mounted as a named volume for persistence +- Not overridable by end users (simplifies container setup) + +## Path Resolution + +All path resolution is centralized in `src/utils/archon-paths.ts`. + +### Core Functions + +```typescript +// Get the Archon home directory +getArchonHome(): string +// Returns: ~/.archon (local) or /.archon (Docker) + +// Get workspaces directory +getArchonWorkspacesPath(): string +// Returns: ~/.archon/workspaces or legacy WORKSPACE_PATH + +// Get worktrees directory +getArchonWorktreesPath(): string +// Returns: ~/.archon/worktrees or legacy WORKTREE_BASE + +// Get global config path +getArchonConfigPath(): string +// Returns: ~/.archon/config.yaml + +// Get command folder search paths (priority order) +getCommandFolderSearchPaths(): string[] +// Returns: ['.archon/commands', '.claude/commands', '.agents/commands'] +``` + +### Docker Detection + +```typescript +function isDocker(): boolean { + return ( + process.env.WORKSPACE_PATH === '/workspace' || + (process.env.HOME === '/root' && Boolean(process.env.WORKSPACE_PATH)) || + process.env.ARCHON_DOCKER === 'true' + ); +} +``` + +### Platform-Specific Paths + +| Platform | `getArchonHome()` | +|----------|-------------------| +| macOS | `/Users//.archon` | +| Linux | `/home//.archon` | +| Windows | `C:\Users\\.archon` | +| Docker | `/.archon` | + +## Configuration System + +### Precedence Chain + +Configuration is resolved in this order (highest to lowest priority): + +1. **Environment Variables** - Secrets, deployment-specific +2. **Global Config** (`~/.archon/config.yaml`) - User preferences +3. **Repo Config** (`.archon/config.yaml`) - Project-specific +4. **Built-in Defaults** - Hardcoded in `src/config/types.ts` + +### Config Loading + +```typescript +// Load merged config for a repo +const config = await loadConfig(repoPath); + +// Load just global config +const globalConfig = await loadGlobalConfig(); + +// Load just repo config +const repoConfig = await loadRepoConfig(repoPath); +``` + +### Hot Reload + +Global config supports hot reload via mtime checking: +- Config is cached after first load +- On subsequent loads, mtime is checked +- If file changed, config is reloaded +- Enables live config changes during development + +## Legacy Compatibility + +### Environment Variables + +These legacy variables are still supported: + +| Legacy | New Default | Behavior | +|--------|-------------|----------| +| `WORKSPACE_PATH` | `~/.archon/workspaces` | Custom path respected if not `./workspace` | +| `WORKTREE_BASE` | `~/.archon/worktrees` | Custom path respected | + +### Command Folders + +Command detection searches in priority order: +1. `.archon/commands/` (new) +2. `.claude/commands/` (legacy) +3. `.agents/commands/` (legacy) + +First match wins. No migration required. + +## Extension Points + +### Adding New Paths + +To add a new managed directory: + +1. Add function to `src/utils/archon-paths.ts`: +```typescript +export function getArchonNewPath(): string { + return join(getArchonHome(), 'new-directory'); +} +``` + +2. Update Docker setup in `Dockerfile` +3. Update volume mounts in `docker-compose.yml` +4. Add tests in `src/utils/archon-paths.test.ts` + +### Adding Config Options + +To add new configuration options: + +1. Add type to `src/config/types.ts`: +```typescript +export interface ArchonConfig { + // ...existing + newFeature?: { + enabled?: boolean; + setting?: string; + }; +} +``` + +2. Add default in `DEFAULT_CONFIG` +3. Use via `loadConfig()` in your code + +## Design Decisions + +### Why `~/.archon/` instead of `~/.config/archon/`? + +- Simpler path (fewer nested directories) +- Follows Claude Code pattern (`~/.claude/`) +- Cross-platform without XDG complexity +- Easy to find and manage manually + +### Why YAML for config? + +- Bun has native support (`Bun.YAML.parse()`) +- Supports comments (unlike JSON) +- Future workflow definitions need YAML +- No external dependencies + +### Why fixed Docker paths? + +- Simplifies container setup +- Predictable volume mounts +- No user confusion about env vars in containers +- Matches convention (apps use fixed paths in containers) + +### Why config precedence chain? + +- Mirrors git config pattern (familiar to developers) +- Secrets stay in env vars (security) +- User preferences in global config (portable) +- Project settings in repo config (version-controlled) + +## Future Considerations + +### Workflow Engine + +The `.archon/workflows/` directory is reserved for: +- YAML workflow definitions +- Multi-step automated processes +- Agent orchestration rules + +### UI Integration + +The config type system is designed for: +- Future web UI configuration +- API-driven config updates +- Real-time config validation + +### Multi-Tenant / SaaS + +Path structure supports future scenarios: +- Per-user isolation +- Organization-level config +- Shared workflow templates +``` + +- **VALIDATE**: Manual review + +--- + +#### Task 5.6: UPDATE New Files to Create list + +**IMPLEMENT**: Add documentation files to the "New Files to Create" section at the top of this plan + +- **ADD** to "New Files to Create" section: +```markdown +- `docs/configuration.md` - Comprehensive configuration reference +- `docs/getting-started.md` - Step-by-step setup guide for new users +- `docs/archon-architecture.md` - Technical architecture docs for developers +``` + +- **VALIDATE**: Manual review of plan coherence + +--- + +## TESTING STRATEGY + +### Unit Tests + +Based on Bun's test framework with mock.module() for isolation: + +- `src/utils/archon-paths.test.ts` - Path resolution with env mocking +- `src/config/loader.test.ts` - Config loading with file system mocking + +### Integration Tests + +- Test path resolution in actual file operations +- Test config loading with real YAML files + +### Edge Cases + +- [ ] Legacy `WORKSPACE_PATH=./workspace` should be ignored +- [ ] Legacy `WORKSPACE_PATH=/custom/path` should be respected +- [ ] Docker detection with various env combinations +- [ ] Tilde expansion in all path-related env vars +- [ ] Missing config files gracefully default +- [ ] Invalid YAML files don't crash startup +- [ ] Config hot reload when file changes + +--- + +## VALIDATION COMMANDS + +Execute every command to ensure zero regressions and 100% feature correctness. + +### Level 1: Syntax & Style + +```bash +# TypeScript type checking +bun run type-check + +# ESLint (must pass with 0 errors) +bun run lint + +# Prettier formatting check +bun run format:check +``` + +**Expected**: All commands pass with exit code 0 + +### Level 2: Unit Tests + +```bash +# Run all tests +bun test + +# Run specific test files +bun test src/utils/archon-paths.test.ts +bun test src/config/loader.test.ts + +# Run with coverage +bun test --coverage +``` + +**Expected**: All tests pass, no regressions + +### Level 3: Build & Docker + +```bash +# Build TypeScript +bun run build + +# Build Docker image +docker build -t remote-coding-agent-test . + +# Verify Docker image labels +docker inspect remote-coding-agent-test | grep -A10 Labels +``` + +**Expected**: Build succeeds, image has OCI labels + +### Level 4: Manual Validation + +```bash +# 1. Start the app +bun run dev + +# Expected logs: +# [Archon] Paths configured: +# Home: /Users/you/.archon +# Workspaces: /Users/you/.archon/workspaces +# Worktrees: /Users/you/.archon/worktrees +# Config: /Users/you/.archon/config.yaml +# [Config] Loaded configuration: +# AI Default: claude +# ... + +# 2. Test via test adapter +curl -X POST http://localhost:3000/test/message \ + -H "Content-Type: application/json" \ + -d '{"conversationId":"test-archon","message":"/status"}' + +# 3. Verify paths in status output +curl http://localhost:3000/test/messages/test-archon | jq + +# 4. Run validation script +./scripts/validate-setup.sh +``` + +### Level 5: Docker Validation + +```bash +# Build and run +docker compose --profile with-db up -d --build + +# Check logs +docker compose logs -f app-with-db + +# Verify paths inside container +docker compose exec app-with-db ls -la /.archon/ + +# Test health endpoint +curl http://localhost:3000/health + +# Cleanup +docker compose --profile with-db down +``` + +--- + +## ACCEPTANCE CRITERIA + +- [ ] All path references use centralized `archon-paths` module +- [ ] Docker images publish to GHCR on release +- [ ] Multi-arch builds work (amd64, arm64) +- [ ] YAML config loads with correct precedence +- [ ] Legacy env vars (`WORKSPACE_PATH`, `WORKTREE_BASE`) still work +- [ ] All validation commands pass with zero errors +- [ ] Unit test coverage maintained (no regression) +- [ ] Documentation updated with new directory structure +- [ ] Startup logs show Archon paths and config +- [ ] README has clear Quick Start for Docker and Local Development +- [ ] `docs/configuration.md` covers all config options +- [ ] `docs/getting-started.md` provides step-by-step guide +- [ ] `docs/cloud-deployment.md` updated for new paths +- [ ] `docs/archon-architecture.md` explains system to developers +- [ ] Windows/Mac/Linux paths documented + +--- + +## COMPLETION CHECKLIST + +- [ ] All Phase 1 tasks completed (Archon Directory Structure) +- [ ] All Phase 2 tasks completed (Docker Distribution) +- [ ] All Phase 3 tasks completed (YAML Configuration) +- [ ] All Phase 4 tasks completed (Developer Experience) +- [ ] All Phase 5 tasks completed (Documentation) +- [ ] Each task validation passed immediately +- [ ] All validation commands executed successfully: + - [ ] Level 1: type-check, lint, format:check + - [ ] Level 2: all tests pass + - [ ] Level 3: build, Docker image builds + - [ ] Level 4: manual testing confirms paths/config + - [ ] Level 5: Docker compose works end-to-end +- [ ] Full test suite passes +- [ ] No linting errors +- [ ] No type checking errors +- [ ] Build succeeds +- [ ] All acceptance criteria met + +--- + +## NOTES + +### Design Decisions + +1. **YAML over JSON for config**: Bun has native support, YAML supports comments, better for workflows +2. **Lazy evaluation for paths**: Functions instead of constants allow test env mocking +3. **Backwards compatibility**: Legacy env vars still work, no migration required +4. **Config precedence**: Env > global > repo > defaults mirrors git config pattern +5. **Hot reload support**: Config cached with mtime check for development + +### Future Considerations + +1. **Workflow Engine**: `.archon/workflows/` directory ready for workflow definitions +2. **UI Integration**: Config types designed for future web UI consumption +3. **SaaS Mode**: Path structure supports future multi-tenant scenarios +4. **Per-Workflow Config**: Type system extensible for workflow-specific settings + +### Risks + +1. **Breaking existing setups**: Mitigated by legacy env var support +2. **Docker volume mounts**: Document required changes for existing deployments +3. **Test pollution during migration**: Clean env var handling in tests + +### Out of Scope + +- Web UI for configuration +- Workflow engine implementation +- Per-workflow configuration loading +- SaaS/multi-tenant features +- CLI wizard for setup (use validation script instead) diff --git a/.claude/commands/validation/validate.md b/.claude/commands/validation/validate.md index aef7ab9f..6c66d1dc 100644 --- a/.claude/commands/validation/validate.md +++ b/.claude/commands/validation/validate.md @@ -78,11 +78,11 @@ source .env PROJECT_ROOT="$(pwd)" export PROJECT_ROOT -# Determine workspace path (use WORKSPACE_PATH from .env or fallback to ./workspace) -if [ -n "$WORKSPACE_PATH" ]; then - WORK_DIR="$WORKSPACE_PATH" +# Determine workspace path (use ARCHON_HOME from .env or fallback to ~/.archon) +if [ -n "$ARCHON_HOME" ]; then + WORK_DIR="${ARCHON_HOME}/workspaces" else - WORK_DIR="workspace" + WORK_DIR="${HOME}/.archon/workspaces" fi echo "Using workspace directory: ${WORK_DIR}" @@ -137,7 +137,7 @@ export WORK_DIR **Why this is needed:** 1. **Workspace cleanup:** The workspace is mounted from the host into the Docker container. If a directory exists on the host, git clone inside the container will fail with "directory already exists". 2. **Database cleanup:** Test adapter conversations (e.g., `test-e2e`) persist across validation runs. Without cleanup, old conversations retain their original `ai_assistant_type` even if `DEFAULT_AI_ASSISTANT` environment variable has changed. This causes the test to use the wrong AI assistant. -3. **WORKSPACE_PATH support:** Reads WORKSPACE_PATH from .env to support custom workspace directories (e.g., `C:\Users\colem\remote-agent-repos` on Windows or `/tmp/workspace` on Linux). +3. **ARCHON_HOME support:** Reads ARCHON_HOME from .env to use a custom base directory. Default: `~/.archon` (workspaces at `~/.archon/workspaces`). 4. **Remote database support:** Works with both local PostgreSQL and remote databases (like Supabase) by using `psql` with the connection string directly, with Node.js fallback. ### 2.1 Store ngrok URL @@ -1438,7 +1438,7 @@ rm -rf "${WORK_DIR}/${TEST_REPO_NAME}" - **Batch Mode**: GitHub responses should be single comments, not streaming (verified in Phase 6-7) - **Database**: Queries use psql (if available) or Node.js fallback - works with both local and remote databases - **Database Validation**: Critical throughout - verifies conversations, sessions, and state transitions -- **Workspace**: Uses WORKSPACE_PATH from .env (or defaults to ./workspace), cleaned automatically at start +- **Workspace**: Uses ARCHON_HOME from .env (or defaults to ~/.archon/workspaces), cleaned automatically at start - **Webhook**: Automatically configured with secret from `.env` ### Database Validation Checkpoints diff --git a/.env.example b/.env.example index 0d9cd1a6..33ecc731 100644 --- a/.env.example +++ b/.env.example @@ -77,16 +77,15 @@ BOT_DISPLAY_NAME=CodingAgent # e.g., "My-bot", "CodeBot", etc. GITHUB_BOT_MENTION=remote-agent # Optional -# WORKSPACE_PATH: Directory where cloned repositories will be stored -# RECOMMENDED: Use a path outside your project directory to avoid nested repos -# Examples: -# - /tmp/remote-agent-workspace (temporary, auto-cleaned on reboot - Linux/Mac) -# - ~/remote-agent-workspace (persistent in home directory - Linux/Mac) -# - C:Users\[your-user-ID]\remote-agent-workspace (Windows) -# AVOID: ./workspace (causes repo-inside-repo when working on this project) -WORKSPACE_PATH= PORT=3000 +# ============================================ +# Archon Directory Configuration (NEW) +# ============================================ +# All Archon-managed files go in ~/.archon/ by default +# Override with ARCHON_HOME to use a custom location +# ARCHON_HOME=~/.archon + # Concurrency MAX_CONCURRENT_CONVERSATIONS=10 # Maximum concurrent AI conversations (default: 10) @@ -95,10 +94,3 @@ MAX_CONCURRENT_CONVERSATIONS=10 # Maximum concurrent AI conversations (default: # (plan, implement, commit, review-pr, etc.) that ship with this repo. # These are updated by the repo maintainers with each release. LOAD_BUILTIN_COMMANDS=true # true (default) | false - -# Worktree Configuration -# Base directory where worktrees are created for GitHub issues/PRs -# Set to same value as skill's worktreeBase for symbiosis with worktree-manager skill -# Default: ${WORKSPACE_PATH}/../worktrees (sibling to workspace) -# Example for skill symbiosis: ~/tmp/worktrees -WORKTREE_BASE= diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 00000000..c89ce6e7 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,61 @@ +name: Publish + +on: + release: + types: [published] + push: + tags: + - 'v*' + workflow_dispatch: + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + docker: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=ref,event=pr + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=sha + type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', 'main') }} + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index bac665b3..e345ee25 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -36,7 +36,7 @@ jobs: # 1. Run all tests EXCEPT orchestrator (which mocks command-handler and factory) # 2. Run orchestrator tests last so its mocks don't affect other test files run: | - bun test --coverage src/db src/utils src/handlers src/clients src/adapters + bun test --coverage src/db src/utils src/handlers src/clients src/adapters src/config bun test --coverage src/orchestrator - name: Upload coverage report diff --git a/CLAUDE.md b/CLAUDE.md index 5ae02949..c7b35fcc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -265,7 +265,7 @@ SLACK_STREAMING_MODE=stream # Default: stream GITHUB_STREAMING_MODE=batch # Default: batch # Optional -WORKSPACE_PATH=/workspace +ARCHON_HOME=~/.archon # Override the base directory PORT=3000 # Builtin Commands (default: true) @@ -280,14 +280,17 @@ The app can work alongside the worktree-manager Claude Code skill. Both use git **To enable symbiosis:** -1. Set `WORKTREE_BASE` to match the skill's `worktreeBase` config: - ```env - WORKTREE_BASE=~/tmp/worktrees +1. Configure the worktree-manager skill to use Archon's worktrees directory: + ```json + // In ~/.claude/settings.json or worktree-manager config + { + "worktreeBase": "~/.archon/worktrees" + } ``` 2. Both systems will use the same directory: - - Skill creates: `~/tmp/worktrees///` - - App creates: `~/tmp/worktrees//-/` + - Skill creates: `~/.archon/worktrees///` + - App creates: `~/.archon/worktrees//-/` 3. The app will **adopt** skill-created worktrees when: - A PR is opened for a branch that already has a worktree @@ -301,6 +304,39 @@ The app can work alongside the worktree-manager Claude Code skill. Both use git Git (`git worktree list`) is the source of truth for what actually exists on disk. +### Archon Directory Structure + +All Archon-managed files are organized under a dedicated namespace: + +**User-level (`~/.archon/`):** +``` +~/.archon/ +├── workspaces/ # Cloned repositories (via /clone) +│ └── owner/repo/ +├── worktrees/ # Git worktrees for isolation +│ └── repo-name/ +│ └── branch-name/ +└── config.yaml # Global configuration (non-secrets) +``` + +**Repo-level (`.archon/` in any repository):** +``` +.archon/ +├── commands/ # Custom command templates +├── workflows/ # Future: workflow definitions +└── config.yaml # Repo-specific configuration +``` + +**For Docker:** Paths are automatically set to `/.archon/`. + +**Configuration:** +- `ARCHON_HOME` - Override the base directory (default: `~/.archon`) + +**Command folder detection priority:** +1. `.archon/commands/` - Archon-specific commands +2. `.claude/commands/` - Claude Code standard location +3. `.agents/commands/` - Alternative location + ## Development Guidelines ### When Creating New Features @@ -599,8 +635,7 @@ try { - Run app locally: `bun run dev` (hot reload enabled) **Volumes:** -- `/workspace` - Cloned repositories -- Mount via `WORKSPACE_PATH` env var +- `/.archon/` - All Archon-managed data (workspaces, worktrees) **Networking:** - App: Port 3000 (configurable via `PORT` env var) diff --git a/Dockerfile b/Dockerfile index 43f80677..f9d3be8a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,10 @@ FROM oven/bun:1-slim +# OCI Labels for GHCR +LABEL org.opencontainers.image.source="https://github.com/dynamous-community/remote-coding-agent" +LABEL org.opencontainers.image.description="Control AI coding assistants remotely from Telegram, Slack, Discord, and GitHub" +LABEL org.opencontainers.image.licenses="MIT" + # Prevent interactive prompts during installation ENV DEBIAN_FRONTEND=noninteractive @@ -26,8 +31,11 @@ RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | d # Create non-root user for running Claude Code # Claude Code refuses to run with --dangerously-skip-permissions as root for security RUN useradd -m -u 1001 -s /bin/bash appuser \ - && mkdir -p /workspace \ - && chown -R appuser:appuser /app /workspace + && chown -R appuser:appuser /app + +# Create Archon directories +RUN mkdir -p /.archon/workspaces /.archon/worktrees \ + && chown -R appuser:appuser /.archon # Copy package files and lockfile COPY package.json bun.lock ./ @@ -53,11 +61,13 @@ USER appuser # Create .codex directory for Codex authentication RUN mkdir -p /home/appuser/.codex -# Configure git to trust /workspace directory +# Configure git to trust Archon directories # This prevents "fatal: detected dubious ownership" errors when git operations # are performed in mounted volumes or repos cloned by different users -RUN git config --global --add safe.directory /workspace && \ - git config --global --add safe.directory '/workspace/*' +RUN git config --global --add safe.directory '/.archon/workspaces' && \ + git config --global --add safe.directory '/.archon/workspaces/*' && \ + git config --global --add safe.directory '/.archon/worktrees' && \ + git config --global --add safe.directory '/.archon/worktrees/*' # Expose port EXPOSE 3000 diff --git a/README.md b/README.md index 72cc10c2..a4adfa38 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,72 @@ Control AI coding assistants (Claude Code, Codex) remotely from Telegram, GitHub --- -**🌐 Production Deployment:** This guide covers local development setup. To deploy remotely for 24/7 operation on a cloud VPS (DigitalOcean, AWS, Linode, etc.), see the **[Cloud Deployment Guide](docs/cloud-deployment.md)**. +## Quick Start + +### Option 1: Docker (Recommended for trying it out) + +```bash +# 1. Get the files +mkdir remote-agent && cd remote-agent +curl -fsSL https://raw.githubusercontent.com/dynamous-community/remote-coding-agent/main/deploy/docker-compose.yml -o docker-compose.yml +curl -fsSL https://raw.githubusercontent.com/dynamous-community/remote-coding-agent/main/deploy/.env.example -o .env + +# 2. Configure (edit .env with your tokens) +nano .env + +# 3. Run +docker compose up -d + +# 4. Check it's working +curl http://localhost:3000/health +``` + +### Option 2: Local Development + +```bash +# 1. Clone and install +git clone https://github.com/dynamous-community/remote-coding-agent +cd remote-coding-agent +bun install + +# 2. Configure +cp .env.example .env +nano .env # Add your tokens + +# 3. Start database +docker compose --profile with-db up -d postgres + +# 4. Run migrations +psql $DATABASE_URL < migrations/000_combined.sql + +# 5. Start with hot reload +bun run dev + +# 6. Validate setup +bun run validate +``` + +### Option 3: Self-Hosted Production + +See [Cloud Deployment Guide](docs/cloud-deployment.md) for deploying to: +- DigitalOcean, Linode, AWS EC2, or any VPS +- With automatic HTTPS via Caddy + +## Directory Structure + +The app uses `~/.archon/` for all managed files: + +``` +~/.archon/ +├── workspaces/ # Cloned repositories +├── worktrees/ # Git worktrees for isolation +└── config.yaml # Optional: global configuration +``` + +On Windows: `C:\Users\\.archon\` +In Docker: `/.archon/` + +See [Configuration Guide](docs/configuration.md) for customization options. --- @@ -54,7 +119,7 @@ cp .env.example .env | `GH_TOKEN` | Repository cloning | [Generate token](https://github.com/settings/tokens) with `repo` scope | | `GITHUB_TOKEN` | Same as `GH_TOKEN` | Use same token value | | `PORT` | HTTP server port | Default: `3000` (optional) | -| `WORKSPACE_PATH` | Clone destination | **Recommended**: `/tmp/remote-agent-workspace` or `~/remote-agent-workspace` (see note below) | +| `ARCHON_HOME` | (Optional) Override base directory | Default: `~/.archon` | **GitHub Personal Access Token Setup:** @@ -68,18 +133,7 @@ GH_TOKEN=ghp_your_token_here GITHUB_TOKEN=ghp_your_token_here # Same value ``` -**⚠️ Important: WORKSPACE_PATH Configuration** - -The `WORKSPACE_PATH` determines where cloned repositories are stored. **Use a path outside your project directory** to avoid issues: - -```env -# Recommended options -WORKSPACE_PATH=~/remote-agent-workspace (persistent in home directory - Linux/Mac) -# or -WORKSPACE_PATH=C:Users\[your-user-ID]\remote-agent-workspace (Windows) -``` - -**Docker note**: Inside containers, the path is always `/workspace` (mapped from your host `WORKSPACE_PATH` in docker-compose.yml). +**Note:** Repository clones are stored in `~/.archon/workspaces/` by default (or `/.archon/workspaces/` in Docker). Set `ARCHON_HOME` to override the base directory. **Database Setup - Choose One:** diff --git a/bun.lock b/bun.lock index 25fb4737..c80f7741 100644 --- a/bun.lock +++ b/bun.lock @@ -15,6 +15,7 @@ "pg": "^8.11.0", "telegraf": "^4.16.0", "telegramify-markdown": "^1.3.0", + "yaml": "^2.7.1", }, "devDependencies": { "@eslint/js": "^9.39.1", @@ -732,6 +733,8 @@ "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], + "yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="], + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], diff --git a/deploy/.env.example b/deploy/.env.example new file mode 100644 index 00000000..5e46c501 --- /dev/null +++ b/deploy/.env.example @@ -0,0 +1,48 @@ +# Remote Coding Agent - Environment Configuration +# Copy to .env and fill in your values + +# ============================================ +# Required: Database +# ============================================ +# Use a managed PostgreSQL (Supabase, Neon, etc.) +DATABASE_URL=postgresql://user:password@host:5432/dbname + +# Or uncomment postgres service in docker-compose.yml and use: +# DATABASE_URL=postgresql://postgres:postgres@postgres:5432/remote_coding_agent + +# ============================================ +# Required: AI Assistant (at least one) +# ============================================ +# Claude (recommended) - Get token: claude setup-token +CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-... + +# Or Codex - Get from ~/.codex/auth.json after: codex login +# CODEX_ID_TOKEN=... +# CODEX_ACCESS_TOKEN=... +# CODEX_REFRESH_TOKEN=... +# CODEX_ACCOUNT_ID=... + +# ============================================ +# Required: Platform (at least one) +# ============================================ +# Telegram - Create bot via @BotFather +TELEGRAM_BOT_TOKEN=123456789:ABC... + +# Discord - Create bot at discord.com/developers +# DISCORD_BOT_TOKEN=... + +# Slack - Create app at api.slack.com/apps +# SLACK_BOT_TOKEN=xoxb-... +# SLACK_APP_TOKEN=xapp-... + +# GitHub Webhooks +# GH_TOKEN=ghp_... +# GITHUB_TOKEN=ghp_... +# WEBHOOK_SECRET=... + +# ============================================ +# Optional +# ============================================ +PORT=3000 +# TELEGRAM_STREAMING_MODE=stream +# DISCORD_STREAMING_MODE=batch diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 00000000..eae46269 --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,45 @@ +# Remote Coding Agent - Docker Compose for End Users +# +# Usage: +# 1. Copy this file and .env.example to your server +# 2. Rename .env.example to .env and configure +# 3. Run: docker compose up -d +# +# For full documentation, see: +# https://github.com/dynamous-community/remote-coding-agent + +services: + app: + image: ghcr.io/dynamous-community/remote-coding-agent:latest + restart: unless-stopped + env_file: .env + ports: + - "${PORT:-3000}:3000" + volumes: + - archon_data:/.archon + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + + # Uncomment to run PostgreSQL locally + # postgres: + # image: postgres:16-alpine + # restart: unless-stopped + # environment: + # POSTGRES_DB: remote_coding_agent + # POSTGRES_USER: postgres + # POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} + # volumes: + # - postgres_data:/var/lib/postgresql/data + # healthcheck: + # test: ["CMD-SHELL", "pg_isready -U postgres"] + # interval: 10s + # timeout: 5s + # retries: 5 + +volumes: + archon_data: + # postgres_data: diff --git a/docker-compose.yml b/docker-compose.yml index ed5c3816..3c0c89cb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,12 +6,12 @@ services: build: . env_file: .env environment: - # Override WORKSPACE_PATH for container (host path differs from container path) - WORKSPACE_PATH: /workspace + # Signal Docker environment for Archon path detection + ARCHON_DOCKER: "true" ports: - "${PORT:-3000}:${PORT:-3000}" volumes: - - ${WORKSPACE_PATH:-./workspace}:/workspace + - archon_data:/.archon # All Archon-managed data (workspaces, worktrees, config) restart: unless-stopped dns: - 8.8.8.8 @@ -28,12 +28,12 @@ services: environment: # Override DATABASE_URL to use Docker service name DATABASE_URL: postgresql://postgres:postgres@postgres:5432/remote_coding_agent - # Override WORKSPACE_PATH for container (host path differs from container path) - WORKSPACE_PATH: /workspace + # Signal Docker environment for Archon path detection + ARCHON_DOCKER: "true" ports: - "${PORT:-3000}:${PORT:-3000}" volumes: - - ${WORKSPACE_PATH:-./workspace}:/workspace + - archon_data:/.archon # All Archon-managed data (workspaces, worktrees, config) restart: unless-stopped dns: - 8.8.8.8 @@ -67,3 +67,4 @@ services: volumes: postgres_data: + archon_data: # Persistent Archon data diff --git a/docs/architecture.md b/docs/architecture.md index 0db1c399..d42ca5e0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -528,15 +528,15 @@ export class WorktreeProvider implements IIsolationProvider { ### Storage Location ``` -LOCAL: ~/tmp/worktrees/// ← WORKTREE_BASE can override -DOCKER: /workspace/worktrees/// ← FIXED, no override +LOCAL: ~/.archon/worktrees/// ← ARCHON_HOME can override base +DOCKER: /.archon/worktrees/// ← FIXED, no override ``` **Logic in `getWorktreeBase()`:** -1. Docker detected? → `/workspace/worktrees` (always, no override) -2. `WORKTREE_BASE` set? → use it (local only) -3. Default → `~/tmp/worktrees` +1. Docker detected? → `/.archon/worktrees` (always, no override) +2. `ARCHON_HOME` set? → `${ARCHON_HOME}/worktrees` +3. Default → `~/.archon/worktrees` ### Usage Pattern diff --git a/docs/archon-architecture.md b/docs/archon-architecture.md new file mode 100644 index 00000000..5344c0be --- /dev/null +++ b/docs/archon-architecture.md @@ -0,0 +1,238 @@ +# Archon Architecture + +This document explains the Archon directory structure and configuration system for developers contributing to or extending the remote-coding-agent. + +## Overview + +Archon is the unified directory and configuration system for the remote-coding-agent. It provides: + +1. **Consistent paths** across all platforms (Mac, Linux, Windows, Docker) +2. **Configuration precedence** chain (env > global > repo > defaults) +3. **Future-ready structure** for workflow engine and UI integration + +## Directory Structure + +### User-Level: `~/.archon/` + +``` +~/.archon/ # ARCHON_HOME +├── workspaces/ # Cloned repositories +│ └── owner/ +│ └── repo/ +├── worktrees/ # Git worktrees for isolation +│ └── repo-name/ +│ └── branch-name/ +└── config.yaml # Global user configuration +``` + +**Purpose:** +- `workspaces/` - Repositories cloned via `/clone` command or GitHub adapter +- `worktrees/` - Isolated git worktrees created per conversation/issue/PR +- `config.yaml` - Non-secret user preferences + +### Repo-Level: `.archon/` + +``` +any-repo/.archon/ +├── commands/ # Custom command templates +│ ├── plan.md +│ └── execute.md +├── workflows/ # Future: workflow definitions +│ └── pr-review.yaml +└── config.yaml # Repo-specific configuration +``` + +**Purpose:** +- `commands/` - Slash command templates (priority over `.claude/commands/`, `.agents/commands/`) +- `workflows/` - Future workflow engine definitions +- `config.yaml` - Project-specific settings + +### Docker: `/.archon/` + +In Docker containers, the Archon home is fixed at `/.archon/` (root level). This is: +- Mounted as a named volume for persistence +- Not overridable by end users (simplifies container setup) + +## Path Resolution + +All path resolution is centralized in `src/utils/archon-paths.ts`. + +### Core Functions + +```typescript +// Get the Archon home directory +getArchonHome(): string +// Returns: ~/.archon (local) or /.archon (Docker) + +// Get workspaces directory +getArchonWorkspacesPath(): string +// Returns: ${ARCHON_HOME}/workspaces + +// Get worktrees directory +getArchonWorktreesPath(): string +// Returns: ${ARCHON_HOME}/worktrees + +// Get global config path +getArchonConfigPath(): string +// Returns: ${ARCHON_HOME}/config.yaml + +// Get command folder search paths (priority order) +getCommandFolderSearchPaths(): string[] +// Returns: ['.archon/commands', '.claude/commands', '.agents/commands'] +``` + +### Docker Detection + +```typescript +function isDocker(): boolean { + return ( + process.env.WORKSPACE_PATH === '/workspace' || + (process.env.HOME === '/root' && Boolean(process.env.WORKSPACE_PATH)) || + process.env.ARCHON_DOCKER === 'true' + ); +} +``` + +### Platform-Specific Paths + +| Platform | `getArchonHome()` | +|----------|-------------------| +| macOS | `/Users//.archon` | +| Linux | `/home//.archon` | +| Windows | `C:\Users\\.archon` | +| Docker | `/.archon` | + +## Configuration System + +### Precedence Chain + +Configuration is resolved in this order (highest to lowest priority): + +1. **Environment Variables** - Secrets, deployment-specific +2. **Global Config** (`~/.archon/config.yaml`) - User preferences +3. **Repo Config** (`.archon/config.yaml`) - Project-specific +4. **Built-in Defaults** - Hardcoded in `src/config/config-types.ts` + +### Config Loading + +```typescript +// Load merged config for a repo +const config = await loadConfig(repoPath); + +// Load just global config +const globalConfig = await loadGlobalConfig(); + +// Load just repo config +const repoConfig = await loadRepoConfig(repoPath); +``` + +### Configuration Options + +Key configuration options: + +| Option | Env Override | Default | +|--------|--------------|---------| +| `ARCHON_HOME` | `ARCHON_HOME` | `~/.archon` | +| Default AI Assistant | `DEFAULT_AI_ASSISTANT` | `claude` | +| Telegram Streaming | `TELEGRAM_STREAMING_MODE` | `stream` | +| Discord Streaming | `DISCORD_STREAMING_MODE` | `batch` | +| Slack Streaming | `SLACK_STREAMING_MODE` | `batch` | +| GitHub Streaming | `GITHUB_STREAMING_MODE` | `batch` | + +## Command Folders + +Command detection searches in priority order: + +1. `.archon/commands/` - Archon-specific commands +2. `.claude/commands/` - Claude Code standard location +3. `.agents/commands/` - Alternative location + +First match wins. No migration required. + +## Extension Points + +### Adding New Paths + +To add a new managed directory: + +1. Add function to `src/utils/archon-paths.ts`: +```typescript +export function getArchonNewPath(): string { + return join(getArchonHome(), 'new-directory'); +} +``` + +2. Update Docker setup in `Dockerfile` +3. Update volume mounts in `docker-compose.yml` +4. Add tests in `src/utils/archon-paths.test.ts` + +### Adding Config Options + +To add new configuration options: + +1. Add type to `src/config/config-types.ts`: +```typescript +export interface GlobalConfig { + // ...existing + newFeature?: { + enabled?: boolean; + setting?: string; + }; +} +``` + +2. Add default in `getDefaults()` function +3. Use via `loadConfig()` in your code + +## Design Decisions + +### Why `~/.archon/` instead of `~/.config/archon/`? + +- Simpler path (fewer nested directories) +- Follows Claude Code pattern (`~/.claude/`) +- Cross-platform without XDG complexity +- Easy to find and manage manually + +### Why YAML for config? + +- Bun has native support (via `yaml` package) +- Supports comments (unlike JSON) +- Future workflow definitions need YAML +- Human-readable and editable + +### Why fixed Docker paths? + +- Simplifies container setup +- Predictable volume mounts +- No user confusion about env vars in containers +- Matches convention (apps use fixed paths in containers) + +### Why config precedence chain? + +- Mirrors git config pattern (familiar to developers) +- Secrets stay in env vars (security) +- User preferences in global config (portable) +- Project settings in repo config (version-controlled) + +## Future Considerations + +### Workflow Engine + +The `.archon/workflows/` directory is reserved for: +- YAML workflow definitions +- Multi-step automated processes +- Agent orchestration rules + +### UI Integration + +The config type system is designed for: +- Future web UI configuration +- API-driven config updates +- Real-time config validation + +### Multi-Tenant / SaaS + +Path structure supports future scenarios: +- Per-user isolation +- Organization-level config +- Shared workflow templates diff --git a/docs/cloud-deployment.md b/docs/cloud-deployment.md index dd1d8279..aebc0833 100644 --- a/docs/cloud-deployment.md +++ b/docs/cloud-deployment.md @@ -260,7 +260,7 @@ GITHUB_TOKEN=ghp_your_token_here # Server settings PORT=3000 -WORKSPACE_PATH=/tmp/remote-agent-workspace # Use external path to avoid nested repos +ARCHON_HOME=/tmp/archon # Override base directory (optional) ``` **GitHub Token Setup:** diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 00000000..ccf7b658 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,147 @@ +# Configuration Guide + +Archon supports a layered configuration system with sensible defaults, optional YAML config files, and environment variable overrides. + +## Directory Structure + +### User-Level (~/.archon/) + +``` +~/.archon/ +├── workspaces/ # Cloned repositories +│ └── owner/repo/ +├── worktrees/ # Git worktrees for isolation +│ └── repo-name/ +│ └── branch-name/ +└── config.yaml # Global configuration (optional) +``` + +### Repository-Level (.archon/) + +``` +.archon/ +├── commands/ # Custom command templates +│ └── plan.md +├── workflows/ # Future: workflow definitions +└── config.yaml # Repo-specific configuration (optional) +``` + +## Configuration Priority + +Settings are loaded in this order (later overrides earlier): + +1. **Defaults** - Sensible built-in defaults +2. **Global Config** - `~/.archon/config.yaml` +3. **Repo Config** - `.archon/config.yaml` in repository +4. **Environment Variables** - Always highest priority + +## Global Configuration + +Create `~/.archon/config.yaml` for user-wide preferences: + +```yaml +# Default AI assistant +defaultAssistant: claude # or 'codex' + +# Streaming preferences per platform +streaming: + telegram: stream # 'stream' or 'batch' + discord: batch + slack: batch + github: batch + +# Custom paths (usually not needed) +paths: + workspaces: ~/.archon/workspaces + worktrees: ~/.archon/worktrees + +# Concurrency limits +concurrency: + maxConversations: 10 +``` + +## Repository Configuration + +Create `.archon/config.yaml` in any repository for project-specific settings: + +```yaml +# AI assistant for this project +assistant: claude + +# Commands configuration +commands: + folder: .archon/commands + autoLoad: true + +# Worktree settings +worktree: + baseBranch: main +``` + +## Environment Variables + +Environment variables override all other configuration: + +| Variable | Description | Default | +| ------------------------------ | -------------------------- | ------------- | +| `ARCHON_HOME` | Base directory for Archon | `~/.archon` | +| `DEFAULT_AI_ASSISTANT` | Default AI assistant | `claude` | +| `TELEGRAM_STREAMING_MODE` | Telegram streaming | `stream` | +| `DISCORD_STREAMING_MODE` | Discord streaming | `batch` | +| `SLACK_STREAMING_MODE` | Slack streaming | `batch` | +| `GITHUB_STREAMING_MODE` | GitHub streaming | `batch` | +| `MAX_CONCURRENT_CONVERSATIONS` | Concurrency limit | `10` | + +## Docker Configuration + +In Docker containers, paths are automatically set: + +``` +/.archon/ +├── workspaces/ +└── worktrees/ +``` + +Environment variables still work and override defaults. + +## Command Folder Detection + +When cloning or switching repositories, Archon looks for commands in this priority order: + +1. `.archon/commands/` - Archon-specific commands +2. `.claude/commands/` - Claude Code standard location +3. `.agents/commands/` - Alternative location + +First found folder is used. + +## Examples + +### Minimal Setup (Using Defaults) + +No configuration needed! Archon works out of the box with: + +- `~/.archon/` for all managed files +- Claude as default AI assistant +- Platform-appropriate streaming modes + +### Custom AI Preference + +```yaml +# ~/.archon/config.yaml +defaultAssistant: codex +``` + +### Project-Specific Settings + +```yaml +# .archon/config.yaml in your repo +assistant: claude +commands: + autoLoad: true +``` + +### Docker with Custom Volume + +```bash +docker run -v /my/data:/.archon ghcr.io/dynamous-community/remote-coding-agent +``` diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 00000000..7a309e41 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,190 @@ +# Getting Started + +This guide walks you through setting up the Remote Coding Agent from scratch. + +## Prerequisites + +Before you begin, you'll need: + +1. **Docker** (recommended) or **Bun** runtime +2. **PostgreSQL** database (local or managed like Supabase/Neon) +3. **AI Assistant credentials** (Claude or Codex) +4. **Platform credentials** (Telegram, Discord, Slack, or GitHub) + +## Step 1: Choose Your Setup Method + +| Method | Best For | Time | +|--------|----------|------| +| [Docker Quick Start](#docker-quick-start) | Trying it out, production | ~10 min | +| [Local Development](#local-development) | Contributing, customizing | ~15 min | +| [Cloud Deployment](cloud-deployment.md) | 24/7 self-hosted | ~30 min | + +## Docker Quick Start + +### 1.1 Get the Files + +```bash +mkdir remote-agent && cd remote-agent + +# Download docker-compose and env template +curl -fsSL https://raw.githubusercontent.com/dynamous-community/remote-coding-agent/main/deploy/docker-compose.yml -o docker-compose.yml +curl -fsSL https://raw.githubusercontent.com/dynamous-community/remote-coding-agent/main/deploy/.env.example -o .env +``` + +### 1.2 Get Your Credentials + +#### Database + +**Option A: Use a managed database (recommended)** +1. Create a free database at [Supabase](https://supabase.com) or [Neon](https://neon.tech) +2. Copy the connection string + +**Option B: Run PostgreSQL locally** +- Uncomment the postgres service in docker-compose.yml +- Use: `postgresql://postgres:postgres@postgres:5432/remote_coding_agent` + +#### AI Assistant + +**Claude (recommended):** +1. Install Claude Code CLI: https://docs.anthropic.com/claude-code +2. Run: `claude setup-token` +3. Copy the token (starts with `sk-ant-oat01-`) + +**Codex:** +1. Run: `codex login` +2. Copy credentials from `~/.codex/auth.json` + +#### Platform (choose at least one) + +**Telegram:** +1. Message [@BotFather](https://t.me/BotFather) on Telegram +2. Send `/newbot` and follow prompts +3. Copy the bot token + +**Discord:** +1. Go to [Discord Developer Portal](https://discord.com/developers/applications) +2. Create New Application > Bot > Reset Token +3. Enable MESSAGE CONTENT INTENT in Bot settings +4. Copy the bot token + +**Slack:** +1. Go to [Slack API](https://api.slack.com/apps) +2. Create New App > From Scratch +3. See [Slack Setup Guide](slack-setup.md) for detailed steps + +### 1.3 Configure + +Edit `.env` with your credentials: + +```bash +nano .env +``` + +At minimum, set: +- `DATABASE_URL` +- One AI assistant (`CLAUDE_CODE_OAUTH_TOKEN` or Codex credentials) +- One platform (`TELEGRAM_BOT_TOKEN`, `DISCORD_BOT_TOKEN`, etc.) + +### 1.4 Start + +```bash +docker compose up -d +``` + +### 1.5 Verify + +```bash +# Check health +curl http://localhost:3000/health +# Expected: {"status":"ok"} + +# Check database +curl http://localhost:3000/health/db +# Expected: {"status":"ok","database":"connected"} +``` + +### 1.6 Test Your Bot + +Send a message to your bot: +- **Telegram**: Message your bot with `/help` +- **Discord**: Mention your bot with `@botname /help` +- **Slack**: Message your bot with `/help` + +## Local Development + +### 2.1 Clone and Install + +```bash +git clone https://github.com/dynamous-community/remote-coding-agent +cd remote-coding-agent +bun install +``` + +### 2.2 Configure + +```bash +cp .env.example .env +nano .env # Add your credentials (same as Docker method) +``` + +### 2.3 Start Database + +```bash +docker compose --profile with-db up -d postgres +``` + +### 2.4 Run Migrations + +```bash +psql $DATABASE_URL < migrations/000_combined.sql +``` + +### 2.5 Validate Setup + +```bash +bun run setup:check +``` + +### 2.6 Start Development Server + +```bash +bun run dev +``` + +The server starts with hot reload. Changes to code automatically restart. + +## Next Steps + +- [Configuration Guide](configuration.md) - Customize settings +- [Command System](../CLAUDE.md#command-system-patterns) - Create custom commands +- [Cloud Deployment](cloud-deployment.md) - Deploy for 24/7 operation + +## Troubleshooting + +### "Database connection failed" + +1. Check `DATABASE_URL` is correct +2. For managed DB: Ensure IP is whitelisted +3. For local: Ensure postgres container is running: `docker compose ps` + +### "No AI assistant credentials found" + +Set at least one of: +- `CLAUDE_CODE_OAUTH_TOKEN` (recommended) +- `CLAUDE_API_KEY` +- `CODEX_ID_TOKEN` + `CODEX_ACCESS_TOKEN` + `CODEX_REFRESH_TOKEN` + +### "Bot not responding" + +1. Check logs: `docker compose logs -f app` or terminal output for `bun run dev` +2. Verify bot token is correct +3. For Discord: Ensure MESSAGE CONTENT INTENT is enabled +4. For Slack: Ensure Socket Mode is enabled + +### Archon Directory Not Created + +The `~/.archon/` directory is created automatically on first use. To create manually: + +```bash +mkdir -p ~/.archon/workspaces ~/.archon/worktrees +``` diff --git a/docs/worktree-orchestration.md b/docs/worktree-orchestration.md index f0a1afa5..7ced0412 100644 --- a/docs/worktree-orchestration.md +++ b/docs/worktree-orchestration.md @@ -5,16 +5,16 @@ ## Storage Location ``` -LOCAL: ~/tmp/worktrees/// ← WORKTREE_BASE can override -DOCKER: /workspace/worktrees/// ← FIXED, no override +LOCAL: ~/.archon/worktrees/// ← ARCHON_HOME can override base +DOCKER: /.archon/worktrees/// ← FIXED, no override ``` Detection order in `getWorktreeBase()`: ``` -1. isDocker? → /workspace/worktrees (ALWAYS) -2. WORKTREE_BASE set? → use it (local only) -3. default → ~/tmp/worktrees +1. isDocker? → /.archon/worktrees (ALWAYS) +2. ARCHON_HOME set? → ${ARCHON_HOME}/worktrees +3. default → ~/.archon/worktrees ``` ## Architecture diff --git a/package.json b/package.json index bd5ae509..d46b3313 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,9 @@ "lint": "bun x eslint . --cache", "lint:fix": "bun x eslint . --cache --fix", "format": "bun x prettier --write .", - "format:check": "bun x prettier --check ." + "format:check": "bun x prettier --check .", + "validate": "bun run type-check && bun run lint && bun test", + "setup:check": "./scripts/validate-setup.sh" }, "keywords": [ "ai", @@ -39,7 +41,8 @@ "express": "^5.2.1", "pg": "^8.11.0", "telegraf": "^4.16.0", - "telegramify-markdown": "^1.3.0" + "telegramify-markdown": "^1.3.0", + "yaml": "^2.7.1" }, "devDependencies": { "@eslint/js": "^9.39.1", diff --git a/scripts/validate-setup.sh b/scripts/validate-setup.sh new file mode 100755 index 00000000..10789245 --- /dev/null +++ b/scripts/validate-setup.sh @@ -0,0 +1,176 @@ +#!/bin/bash +# validate-setup.sh - Validate Remote Coding Agent configuration +# +# Usage: ./scripts/validate-setup.sh + +set -e + +echo "Remote Coding Agent Setup Validator" +echo "=======================================" +echo "" + +ERRORS=0 +WARNINGS=0 + +# Color codes +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +check_pass() { + echo -e "${GREEN}✓${NC} $1" +} + +check_fail() { + echo -e "${RED}✗${NC} $1" + ((ERRORS++)) +} + +check_warn() { + echo -e "${YELLOW}!${NC} $1" + ((WARNINGS++)) +} + +# Check .env file +echo "Configuration Files" +echo "----------------------" + +if [ -f ".env" ]; then + check_pass ".env file exists" +else + check_fail ".env file not found (copy from .env.example)" +fi + +# Check required environment variables +echo "" +echo "Required Environment Variables" +echo "----------------------------------" + +# Load .env if exists +if [ -f ".env" ]; then + set -a + source .env 2>/dev/null || true + set +a +fi + +if [ -n "$DATABASE_URL" ]; then + check_pass "DATABASE_URL is set" +else + check_fail "DATABASE_URL not set" +fi + +# AI Assistants +echo "" +echo "AI Assistants" +echo "----------------" + +if [ -n "$CLAUDE_CODE_OAUTH_TOKEN" ] || [ -n "$CLAUDE_API_KEY" ]; then + check_pass "Claude credentials configured" +else + check_warn "Claude credentials not found" +fi + +if [ -n "$CODEX_ID_TOKEN" ] && [ -n "$CODEX_ACCESS_TOKEN" ]; then + check_pass "Codex credentials configured" +else + check_warn "Codex credentials not found" +fi + +if [ -z "$CLAUDE_CODE_OAUTH_TOKEN" ] && [ -z "$CLAUDE_API_KEY" ] && [ -z "$CODEX_ID_TOKEN" ]; then + check_fail "No AI assistant credentials found (need at least one)" +fi + +# Platforms +echo "" +echo "Platform Adapters" +echo "--------------------" + +PLATFORMS=0 + +if [ -n "$TELEGRAM_BOT_TOKEN" ]; then + check_pass "Telegram configured" + ((PLATFORMS++)) +else + check_warn "Telegram not configured" +fi + +if [ -n "$DISCORD_BOT_TOKEN" ]; then + check_pass "Discord configured" + ((PLATFORMS++)) +else + check_warn "Discord not configured" +fi + +if [ -n "$SLACK_BOT_TOKEN" ] && [ -n "$SLACK_APP_TOKEN" ]; then + check_pass "Slack configured" + ((PLATFORMS++)) +else + check_warn "Slack not configured" +fi + +if [ -n "$GITHUB_TOKEN" ] && [ -n "$WEBHOOK_SECRET" ]; then + check_pass "GitHub webhooks configured" + ((PLATFORMS++)) +else + check_warn "GitHub webhooks not configured" +fi + +if [ $PLATFORMS -eq 0 ]; then + check_fail "No platform adapters configured (need at least one)" +fi + +# Docker +echo "" +echo "Docker" +echo "---------" + +if command -v docker &> /dev/null; then + check_pass "Docker is installed" + + if docker compose version &> /dev/null; then + check_pass "Docker Compose is available" + else + check_warn "Docker Compose not found" + fi +else + check_warn "Docker not installed (required for containerized deployment)" +fi + +# Archon paths +echo "" +echo "Archon Paths" +echo "---------------" + +ARCHON_HOME="${ARCHON_HOME:-$HOME/.archon}" +echo " Home: $ARCHON_HOME" +echo " Workspaces: $ARCHON_HOME/workspaces" +echo " Worktrees: $ARCHON_HOME/worktrees" + +if [ -d "$ARCHON_HOME" ]; then + check_pass "Archon home directory exists" +else + check_warn "Archon home directory will be created on first run" +fi + +# Summary +echo "" +echo "=======================================" +if [ $ERRORS -gt 0 ]; then + echo -e "${RED}Validation failed with $ERRORS error(s) and $WARNINGS warning(s)${NC}" + echo "" + echo "Please fix the errors above before running the application." + exit 1 +elif [ $WARNINGS -gt 0 ]; then + echo -e "${YELLOW}Validation passed with $WARNINGS warning(s)${NC}" + echo "" + echo "The application should work, but some features may be unavailable." + exit 0 +else + echo -e "${GREEN}All checks passed!${NC}" + echo "" + echo "You can start the application with:" + echo " bun run dev # Development with hot reload" + echo " docker compose up -d # Docker deployment" + exit 0 +fi diff --git a/src/adapters/github.ts b/src/adapters/github.ts index 62d73de9..107222a8 100644 --- a/src/adapters/github.ts +++ b/src/adapters/github.ts @@ -12,11 +12,12 @@ import * as codebaseDb from '../db/codebases'; import { exec } from 'child_process'; import { promisify } from 'util'; import { readdir, access } from 'fs/promises'; -import { join, resolve } from 'path'; +import { join } from 'path'; import { parseAllowedUsers, isGitHubUserAuthorized } from '../utils/github-auth'; import { getLinkedIssueNumbers } from '../utils/github-graphql'; import { onConversationClosed } from '../services/cleanup-service'; import { isWorktreePath } from '../utils/git'; +import { getArchonWorkspacesPath, getCommandFolderSearchPaths } from '../utils/archon-paths'; const execAsync = promisify(exec); @@ -365,10 +366,10 @@ export class GitHubAdapter implements IPlatformAdapter { } /** - * Auto-detect and load commands from .claude/commands or .agents/commands + * Auto-detect and load commands from .archon/commands, .claude/commands or .agents/commands */ private async autoDetectAndLoadCommands(repoPath: string, codebaseId: string): Promise { - const commandFolders = ['.claude/commands', '.agents/commands']; + const commandFolders = getCommandFolderSearchPaths(); for (const folder of commandFolders) { try { @@ -417,7 +418,7 @@ export class GitHubAdapter implements IPlatformAdapter { // Canonical path includes owner to prevent collisions between repos with same name // e.g., alice/utils and bob/utils get separate directories - const canonicalPath = join(resolve(process.env.WORKSPACE_PATH ?? '/workspace'), owner, repo); + const canonicalPath = join(getArchonWorkspacesPath(), owner, repo); if (existing) { // Check if existing codebase points to a worktree path - fix it if so diff --git a/src/config/config-loader.test.ts b/src/config/config-loader.test.ts new file mode 100644 index 00000000..cb0a32df --- /dev/null +++ b/src/config/config-loader.test.ts @@ -0,0 +1,193 @@ +import { describe, test, expect, beforeEach, afterEach, mock } from 'bun:test'; +import { homedir } from 'os'; +import { join } from 'path'; +import * as fsPromises from 'fs/promises'; + +// Store original readFile for passthrough +const originalReadFile = fsPromises.readFile; + +// Mock readFile - defaults to calling original implementation +const mockReadFile = mock(originalReadFile); +mock.module('fs/promises', () => ({ + ...fsPromises, + readFile: mockReadFile, +})); + +import { loadGlobalConfig, loadRepoConfig, loadConfig, clearConfigCache } from './config-loader'; + +describe('config-loader', () => { + const originalEnv: Record = {}; + const envVars = [ + 'DEFAULT_AI_ASSISTANT', + 'TELEGRAM_STREAMING_MODE', + 'DISCORD_STREAMING_MODE', + 'SLACK_STREAMING_MODE', + 'GITHUB_STREAMING_MODE', + 'MAX_CONCURRENT_CONVERSATIONS', + 'WORKSPACE_PATH', + 'WORKTREE_BASE', + 'ARCHON_HOME', + ]; + + beforeEach(() => { + clearConfigCache(); + mockReadFile.mockReset(); + + // Save original env vars + envVars.forEach(key => { + originalEnv[key] = process.env[key]; + delete process.env[key]; + }); + }); + + afterEach(() => { + // Restore env vars + envVars.forEach(key => { + if (originalEnv[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = originalEnv[key]; + } + }); + + // Restore mock to passthrough mode for other test files + mockReadFile.mockImplementation(originalReadFile); + }); + + describe('loadGlobalConfig', () => { + test('returns empty object when file does not exist', async () => { + const error = new Error('ENOENT') as NodeJS.ErrnoException; + error.code = 'ENOENT'; + mockReadFile.mockRejectedValue(error); + + const config = await loadGlobalConfig(); + expect(config).toEqual({}); + }); + + test('parses valid YAML config', async () => { + mockReadFile.mockResolvedValue(` +defaultAssistant: codex +streaming: + telegram: batch +concurrency: + maxConversations: 5 +`); + + const config = await loadGlobalConfig(); + expect(config.defaultAssistant).toBe('codex'); + expect(config.streaming?.telegram).toBe('batch'); + expect(config.concurrency?.maxConversations).toBe(5); + }); + + test('caches config on subsequent calls', async () => { + mockReadFile.mockResolvedValue('defaultAssistant: claude'); + + await loadGlobalConfig(); + await loadGlobalConfig(); + + // Should only read file once + expect(mockReadFile).toHaveBeenCalledTimes(1); + }); + + test('reloads config when forceReload is true', async () => { + mockReadFile.mockResolvedValue('defaultAssistant: claude'); + + await loadGlobalConfig(); + await loadGlobalConfig(true); + + expect(mockReadFile).toHaveBeenCalledTimes(2); + }); + }); + + describe('loadRepoConfig', () => { + test('loads from .archon/config.yaml', async () => { + mockReadFile.mockImplementation(async (path: string) => { + if (path.includes('.archon/config.yaml')) { + return 'assistant: codex'; + } + throw new Error('Not found'); + }); + + const config = await loadRepoConfig('/test/repo'); + expect(config.assistant).toBe('codex'); + }); + + test('falls back to .claude/config.yaml', async () => { + mockReadFile.mockImplementation(async (path: string) => { + if (path.includes('.claude/config.yaml')) { + return 'assistant: claude'; + } + throw new Error('Not found'); + }); + + const config = await loadRepoConfig('/test/repo'); + expect(config.assistant).toBe('claude'); + }); + + test('returns empty object when no config found', async () => { + mockReadFile.mockRejectedValue(new Error('Not found')); + + const config = await loadRepoConfig('/test/repo'); + expect(config).toEqual({}); + }); + }); + + describe('loadConfig', () => { + test('returns defaults when no configs exist', async () => { + const error = new Error('ENOENT') as NodeJS.ErrnoException; + error.code = 'ENOENT'; + mockReadFile.mockRejectedValue(error); + + const config = await loadConfig(); + + expect(config.assistant).toBe('claude'); + expect(config.streaming.telegram).toBe('stream'); + expect(config.streaming.github).toBe('batch'); + expect(config.concurrency.maxConversations).toBe(10); + }); + + test('env vars override config files', async () => { + mockReadFile.mockResolvedValue(` +defaultAssistant: claude +streaming: + telegram: stream +`); + + process.env.DEFAULT_AI_ASSISTANT = 'codex'; + process.env.TELEGRAM_STREAMING_MODE = 'batch'; + + const config = await loadConfig(); + + expect(config.assistant).toBe('codex'); + expect(config.streaming.telegram).toBe('batch'); + }); + + test('repo config overrides global config', async () => { + let callCount = 0; + mockReadFile.mockImplementation(async (path: string) => { + callCount++; + if (path.includes('.archon/config.yaml') && callCount <= 1) { + return 'defaultAssistant: claude'; + } + if (path.includes('/repo/.archon/config.yaml')) { + return 'assistant: codex'; + } + throw new Error('Not found'); + }); + + const config = await loadConfig('/test/repo'); + expect(config.assistant).toBe('codex'); + }); + + test('paths use archon defaults', async () => { + const error = new Error('ENOENT') as NodeJS.ErrnoException; + error.code = 'ENOENT'; + mockReadFile.mockRejectedValue(error); + + const config = await loadConfig(); + + expect(config.paths.workspaces).toBe(join(homedir(), '.archon', 'workspaces')); + expect(config.paths.worktrees).toBe(join(homedir(), '.archon', 'worktrees')); + }); + }); +}); diff --git a/src/config/config-loader.ts b/src/config/config-loader.ts new file mode 100644 index 00000000..ca878e75 --- /dev/null +++ b/src/config/config-loader.ts @@ -0,0 +1,234 @@ +/** + * Configuration loader for Archon YAML config files + * + * Loading order (later overrides earlier): + * 1. Defaults + * 2. Global config (~/.archon/config.yaml) + * 3. Repository config (.archon/config.yaml) + * 4. Environment variables + */ + +import { readFile } from 'fs/promises'; +import { join } from 'path'; +import { parse as parseYaml } from 'yaml'; +import { + getArchonConfigPath, + getArchonWorkspacesPath, + getArchonWorktreesPath, +} from '../utils/archon-paths'; +import type { GlobalConfig, RepoConfig, MergedConfig } from './config-types'; + +// Cache for loaded configs +let cachedGlobalConfig: GlobalConfig | null = null; + +/** + * Load global config from ~/.archon/config.yaml + * Returns empty object if file doesn't exist + */ +export async function loadGlobalConfig(forceReload = false): Promise { + if (cachedGlobalConfig && !forceReload) { + return cachedGlobalConfig; + } + + const configPath = getArchonConfigPath(); + + try { + const content = await readFile(configPath, 'utf-8'); + cachedGlobalConfig = parseYaml(content) as GlobalConfig; + return cachedGlobalConfig ?? {}; + } catch (error) { + // File doesn't exist or can't be read - return empty config + const err = error as NodeJS.ErrnoException; + if (err.code !== 'ENOENT') { + console.warn(`[Config] Failed to load global config: ${err.message}`); + } + cachedGlobalConfig = {}; + return cachedGlobalConfig; + } +} + +/** + * Load repository config from .archon/config.yaml + * Falls back to .claude/config.yaml for legacy support + * Returns empty object if no config found + */ +export async function loadRepoConfig(repoPath: string): Promise { + const configPaths = [ + join(repoPath, '.archon', 'config.yaml'), + join(repoPath, '.claude', 'config.yaml'), + ]; + + for (const configPath of configPaths) { + try { + const content = await readFile(configPath, 'utf-8'); + return (parseYaml(content) as RepoConfig) ?? {}; + } catch { + // Try next path + continue; + } + } + + // No config found + return {}; +} + +/** + * Get default configuration + */ +function getDefaults(): MergedConfig { + return { + assistant: 'claude', + streaming: { + telegram: 'stream', + discord: 'batch', + slack: 'batch', + github: 'batch', + }, + paths: { + workspaces: getArchonWorkspacesPath(), + worktrees: getArchonWorktreesPath(), + }, + concurrency: { + maxConversations: 10, + }, + }; +} + +/** + * Apply environment variable overrides + */ +function applyEnvOverrides(config: MergedConfig): MergedConfig { + // Assistant override + const envAssistant = process.env.DEFAULT_AI_ASSISTANT; + if (envAssistant === 'claude' || envAssistant === 'codex') { + config.assistant = envAssistant; + } + + // Streaming overrides + const streamingModes = ['stream', 'batch'] as const; + const telegramMode = process.env.TELEGRAM_STREAMING_MODE; + if (telegramMode && streamingModes.includes(telegramMode as 'stream' | 'batch')) { + config.streaming.telegram = telegramMode as 'stream' | 'batch'; + } + + const discordMode = process.env.DISCORD_STREAMING_MODE; + if (discordMode && streamingModes.includes(discordMode as 'stream' | 'batch')) { + config.streaming.discord = discordMode as 'stream' | 'batch'; + } + + const slackMode = process.env.SLACK_STREAMING_MODE; + if (slackMode && streamingModes.includes(slackMode as 'stream' | 'batch')) { + config.streaming.slack = slackMode as 'stream' | 'batch'; + } + + const githubMode = process.env.GITHUB_STREAMING_MODE; + if (githubMode && streamingModes.includes(githubMode as 'stream' | 'batch')) { + config.streaming.github = githubMode as 'stream' | 'batch'; + } + + // Path overrides (these come from archon-paths.ts which already checks env vars) + // No need to re-apply here since getDefaults() uses those functions + + // Concurrency override + const maxConcurrent = process.env.MAX_CONCURRENT_CONVERSATIONS; + if (maxConcurrent) { + const parsed = parseInt(maxConcurrent, 10); + if (!isNaN(parsed) && parsed > 0) { + config.concurrency.maxConversations = parsed; + } + } + + return config; +} + +/** + * Merge global config into defaults + */ +function mergeGlobalConfig(defaults: MergedConfig, global: GlobalConfig): MergedConfig { + const result = { ...defaults }; + + // Assistant preference + if (global.defaultAssistant) { + result.assistant = global.defaultAssistant; + } + + // Streaming preferences + if (global.streaming) { + if (global.streaming.telegram) result.streaming.telegram = global.streaming.telegram; + if (global.streaming.discord) result.streaming.discord = global.streaming.discord; + if (global.streaming.slack) result.streaming.slack = global.streaming.slack; + if (global.streaming.github) result.streaming.github = global.streaming.github; + } + + // Path preferences + if (global.paths) { + if (global.paths.workspaces) result.paths.workspaces = global.paths.workspaces; + if (global.paths.worktrees) result.paths.worktrees = global.paths.worktrees; + } + + // Concurrency preferences + if (global.concurrency?.maxConversations) { + result.concurrency.maxConversations = global.concurrency.maxConversations; + } + + return result; +} + +/** + * Merge repo config into merged config + */ +function mergeRepoConfig(merged: MergedConfig, repo: RepoConfig): MergedConfig { + const result = { ...merged }; + + // Assistant override (repo-level takes precedence) + if (repo.assistant) { + result.assistant = repo.assistant; + } + + return result; +} + +/** + * Load fully merged configuration + * + * @param repoPath - Optional repository path for repo-level config + * @returns Merged configuration with all overrides applied + */ +export async function loadConfig(repoPath?: string): Promise { + // 1. Start with defaults + let config = getDefaults(); + + // 2. Apply global config + const globalConfig = await loadGlobalConfig(); + config = mergeGlobalConfig(config, globalConfig); + + // 3. Apply repo config if path provided + if (repoPath) { + const repoConfig = await loadRepoConfig(repoPath); + config = mergeRepoConfig(config, repoConfig); + } + + // 4. Apply environment overrides (highest precedence) + config = applyEnvOverrides(config); + + return config; +} + +/** + * Clear cached global config (useful for testing) + */ +export function clearConfigCache(): void { + cachedGlobalConfig = null; +} + +/** + * Log current configuration (for startup) + */ +export function logConfig(config: MergedConfig): void { + console.log('[Config] Loaded configuration:'); + console.log(` AI Assistant: ${config.assistant}`); + console.log(` Telegram Streaming: ${config.streaming.telegram}`); + console.log(` Discord Streaming: ${config.streaming.discord}`); + console.log(` Slack Streaming: ${config.streaming.slack}`); + console.log(` GitHub Streaming: ${config.streaming.github}`); +} diff --git a/src/config/config-types.ts b/src/config/config-types.ts new file mode 100644 index 00000000..57c496cc --- /dev/null +++ b/src/config/config-types.ts @@ -0,0 +1,118 @@ +/** + * Configuration types for Archon YAML config files + * + * Two levels: + * - Global: ~/.archon/config.yaml (user preferences) + * - Repository: .archon/config.yaml (project settings) + */ + +/** + * Global configuration (non-secret user preferences) + * Located at ~/.archon/config.yaml + */ +export interface GlobalConfig { + /** + * Default AI assistant when no codebase-specific preference + * @default 'claude' + */ + defaultAssistant?: 'claude' | 'codex'; + + /** + * Platform streaming preferences (can be overridden per conversation) + */ + streaming?: { + telegram?: 'stream' | 'batch'; + discord?: 'stream' | 'batch'; + slack?: 'stream' | 'batch'; + github?: 'stream' | 'batch'; + }; + + /** + * Directory preferences (usually not needed - defaults work well) + */ + paths?: { + /** + * Override workspaces directory + * @default '~/.archon/workspaces' + */ + workspaces?: string; + + /** + * Override worktrees directory + * @default '~/.archon/worktrees' + */ + worktrees?: string; + }; + + /** + * Concurrency limits + */ + concurrency?: { + /** + * Maximum concurrent AI conversations + * @default 10 + */ + maxConversations?: number; + }; +} + +/** + * Repository configuration (project-specific settings) + * Located at .archon/config.yaml in any repository + */ +export interface RepoConfig { + /** + * AI assistant preference for this repository + * Overrides global default + */ + assistant?: 'claude' | 'codex'; + + /** + * Commands configuration + */ + commands?: { + /** + * Custom command folder path (relative to repo root) + * @default '.archon/commands' + */ + folder?: string; + + /** + * Auto-load commands on clone + * @default true + */ + autoLoad?: boolean; + }; + + /** + * Worktree settings for this repository + */ + worktree?: { + /** + * Base branch for worktrees (e.g., 'main', 'develop') + * @default auto-detected from repo + */ + baseBranch?: string; + }; +} + +/** + * Merged configuration (global + repo + env vars) + * Environment variables take precedence + */ +export interface MergedConfig { + assistant: 'claude' | 'codex'; + streaming: { + telegram: 'stream' | 'batch'; + discord: 'stream' | 'batch'; + slack: 'stream' | 'batch'; + github: 'stream' | 'batch'; + }; + paths: { + workspaces: string; + worktrees: string; + }; + concurrency: { + maxConversations: number; + }; +} diff --git a/src/config/index.ts b/src/config/index.ts new file mode 100644 index 00000000..a8287561 --- /dev/null +++ b/src/config/index.ts @@ -0,0 +1,6 @@ +/** + * Configuration module exports + */ + +export * from './config-types'; +export * from './config-loader'; diff --git a/src/handlers/command-handler.test.ts b/src/handlers/command-handler.test.ts index 4578e9ce..c717e49f 100644 --- a/src/handlers/command-handler.test.ts +++ b/src/handlers/command-handler.test.ts @@ -4,6 +4,7 @@ import { describe, test, expect, mock, beforeEach, type Mock } from 'bun:test'; import { Conversation } from '../types'; import { resolve, join } from 'path'; +import * as fsPromises from 'fs/promises'; // Create mock functions const mockUpdateConversation = mock(() => Promise.resolve()); @@ -100,6 +101,7 @@ mock.module('child_process', () => ({ })); mock.module('fs/promises', () => ({ + ...fsPromises, access: mockAccess, readdir: mockReaddir, mkdir: mock(() => Promise.resolve()), diff --git a/src/handlers/command-handler.ts b/src/handlers/command-handler.ts index a40c30c8..ea5cec77 100644 --- a/src/handlers/command-handler.ts +++ b/src/handlers/command-handler.ts @@ -19,6 +19,7 @@ import { getWorktreeStatusBreakdown, MAX_WORKTREES_PER_CODEBASE, } from '../services/cleanup-service'; +import { getArchonWorkspacesPath, getCommandFolderSearchPaths } from '../utils/archon-paths'; /** * Convert an absolute path to a relative path from the repository root @@ -35,7 +36,7 @@ function shortenPath(absolutePath: string, repoRoot?: string): string { } // Fallback: show relative to workspace - const workspacePath = resolve(process.env.WORKSPACE_PATH ?? '/workspace'); + const workspacePath = getArchonWorkspacesPath(); const relPath = relative(workspacePath, absolutePath); if (!relPath.startsWith('..')) { return relPath; @@ -122,7 +123,7 @@ Codebase Commands (per-project): /load-commands - Bulk load (recursive) /command-invoke [args] - Execute /commands - List registered - Note: Commands use relative paths (e.g., .claude/commands) + Note: Commands use relative paths (e.g., .archon/commands) Codebase: /clone - Clone repository @@ -144,7 +145,10 @@ Session: /status - Show state /reset - Clear session /reset-context - Reset AI context, keep worktree - /help - Show help`, + /help - Show help + +Setup: + /init - Create .archon structure in current repo`, }; case 'status': { @@ -224,7 +228,7 @@ Session: const resolvedCwd = resolve(newCwd); // Validate path is within workspace to prevent path traversal - const workspacePath = resolve(process.env.WORKSPACE_PATH ?? '/workspace'); + const workspacePath = getArchonWorkspacesPath(); if (!isPathWithinWorkspace(resolvedCwd)) { return { success: false, message: `Path must be within ${workspacePath} directory` }; } @@ -279,9 +283,9 @@ Session: const repoName = urlParts.pop() ?? 'unknown'; const ownerName = urlParts.pop() ?? 'unknown'; - // Use WORKSPACE_PATH env var for flexibility (local dev vs Docker) + // Use Archon workspaces path (ARCHON_HOME/workspaces or ~/.archon/workspaces) // Include owner in path to prevent collisions (e.g., alice/utils vs bob/utils) - const workspacePath = resolve(process.env.WORKSPACE_PATH ?? '/workspace'); + const workspacePath = getArchonWorkspacesPath(); const targetPath = join(workspacePath, ownerName, repoName); try { @@ -313,7 +317,7 @@ Session: // Check for command folders (same logic as successful clone) let commandFolder: string | null = null; - for (const folder of ['.claude/commands', '.agents/commands']) { + for (const folder of getCommandFolderSearchPaths()) { try { await access(join(targetPath, folder)); commandFolder = folder; @@ -413,7 +417,7 @@ Session: // Auto-load commands if found let commandsLoaded = 0; - for (const folder of ['.claude/commands', '.agents/commands']) { + for (const folder of getCommandFolderSearchPaths()) { try { const commandPath = join(targetPath, folder); await access(commandPath); @@ -468,7 +472,7 @@ Session: const [commandName, commandPath, ...textParts] = args; const commandText = textParts.join(' '); - const workspacePath = resolve(process.env.WORKSPACE_PATH ?? '/workspace'); + const workspacePath = getArchonWorkspacesPath(); const basePath = conversation.cwd ?? workspacePath; const fullPath = resolve(basePath, commandPath); @@ -507,7 +511,7 @@ Session: } const folderPath = args.join(' '); - const workspacePath = resolve(process.env.WORKSPACE_PATH ?? '/workspace'); + const workspacePath = getArchonWorkspacesPath(); const basePath = conversation.cwd ?? workspacePath; const fullPath = resolve(basePath, folderPath); @@ -573,7 +577,7 @@ Session: } case 'repos': { - const workspacePath = resolve(process.env.WORKSPACE_PATH ?? '/workspace'); + const workspacePath = getArchonWorkspacesPath(); try { const entries = await readdir(workspacePath, { withFileTypes: true }); @@ -658,7 +662,7 @@ Session: return { success: false, message: 'Usage: /repo [pull]' }; } - const workspacePath = resolve(process.env.WORKSPACE_PATH ?? '/workspace'); + const workspacePath = getArchonWorkspacesPath(); const identifier = args[0]; const shouldPull = args[1]?.toLowerCase() === 'pull'; @@ -748,7 +752,7 @@ Session: // Auto-load commands if found let commandsLoaded = 0; - for (const folder of ['.claude/commands', '.agents/commands']) { + for (const folder of getCommandFolderSearchPaths()) { try { const commandPath = join(targetPath, folder); await access(commandPath); @@ -793,7 +797,7 @@ Session: return { success: false, message: 'Usage: /repo-remove ' }; } - const workspacePath = resolve(process.env.WORKSPACE_PATH ?? '/workspace'); + const workspacePath = getArchonWorkspacesPath(); const identifier = args[0]; try { @@ -1204,6 +1208,82 @@ Session: } } + case 'init': { + // Create .archon structure in current repo + if (!conversation.cwd) { + return { + success: false, + message: 'No working directory set. Use /clone or /setcwd first.', + }; + } + + const archonDir = join(conversation.cwd, '.archon'); + const commandsDir = join(archonDir, 'commands'); + const configPath = join(archonDir, 'config.yaml'); + + try { + // Check if .archon already exists + try { + await access(archonDir); + return { + success: false, + message: '.archon directory already exists. Nothing to do.', + }; + } catch { + // Directory doesn't exist, we can create it + } + + // Create directories + await import('fs/promises').then(fs => fs.mkdir(commandsDir, { recursive: true })); + + // Create default config.yaml + const defaultConfig = `# Archon repository configuration +# See: https://github.com/dynamous-community/remote-coding-agent + +# AI assistant preference (optional - overrides global default) +# assistant: claude + +# Commands configuration (optional) +# commands: +# folder: .archon/commands +# autoLoad: true +`; + await writeFile(configPath, defaultConfig); + + // Create example command + const exampleCommand = join(commandsDir, 'example.md'); + const exampleContent = `--- +description: Example command template +--- +# Example Command + +This is an example command template. + +Arguments: +- $1 - First positional argument +- $ARGUMENTS - All arguments as string + +Task: $ARGUMENTS +`; + await writeFile(exampleCommand, exampleContent); + + return { + success: true, + message: `Created .archon structure: + .archon/ + ├── config.yaml + └── commands/ + └── example.md + +Use /load-commands .archon/commands to register commands.`, + }; + } catch (error) { + const err = error as Error; + console.error('[Command] init failed:', err); + return { success: false, message: `Failed to initialize: ${err.message}` }; + } + } + default: return { success: false, diff --git a/src/index.ts b/src/index.ts index bcd98952..9e4a9226 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,7 +8,6 @@ import 'dotenv/config'; import express from 'express'; -import { resolve } from 'path'; import { TelegramAdapter } from './adapters/telegram'; import { TestAdapter } from './adapters/test'; import { GitHubAdapter } from './adapters/github'; @@ -20,6 +19,8 @@ import { ConversationLockManager } from './utils/conversation-lock'; import { classifyAndFormatError } from './utils/error-formatter'; import { seedDefaultCommands } from './scripts/seed-commands'; import { startCleanupScheduler, stopCleanupScheduler } from './services/cleanup-service'; +import { logArchonPaths } from './utils/archon-paths'; +import { loadConfig, logConfig } from './config'; async function main(): Promise { console.log('[App] Starting Remote Coding Agent'); @@ -64,16 +65,12 @@ async function main(): Promise { // Start cleanup scheduler startCleanupScheduler(); - // Warn if WORKSPACE_PATH is inside project directory - const workspacePath = resolve(process.env.WORKSPACE_PATH ?? '/workspace'); - const projectRoot = resolve(__dirname, '..'); - if (workspacePath.startsWith(projectRoot + '/') || workspacePath === projectRoot) { - console.warn('⚠️ WARNING: WORKSPACE_PATH is inside project directory'); - console.warn(' This can cause nested repository issues when working on this repo.'); - console.warn(` Current: ${workspacePath}`); - console.warn(' Recommended: /tmp/remote-agent-workspace or ~/remote-agent-workspace'); - console.warn(''); - } + // Log Archon paths configuration + logArchonPaths(); + + // Load and log configuration + const config = await loadConfig(); + logConfig(config); // Seed default command templates await seedDefaultCommands(); diff --git a/src/orchestrator/orchestrator.test.ts b/src/orchestrator/orchestrator.test.ts index 7ab379a3..7a521300 100644 --- a/src/orchestrator/orchestrator.test.ts +++ b/src/orchestrator/orchestrator.test.ts @@ -1,7 +1,8 @@ -import { mock, describe, test, expect, beforeEach } from 'bun:test'; +import { mock, describe, test, expect, beforeEach, afterEach } from 'bun:test'; import { MockPlatformAdapter } from '../test/mocks/platform'; import { Conversation, Codebase, Session } from '../types'; import { join } from 'path'; +import * as fsPromises from 'fs/promises'; // Setup mocks before importing the module under test const mockGetOrCreateConversation = mock(() => Promise.resolve(null)); @@ -20,7 +21,10 @@ const mockParseCommand = mock((message: string) => { return { command: parts[0].substring(1), args: parts.slice(1) }; }); const mockGetAssistantClient = mock(() => null); -const mockReadFile = mock(() => Promise.resolve('')); + +// Store original readFile for passthrough +const originalReadFile = fsPromises.readFile; +const mockReadFile = mock(originalReadFile); // Isolation environment mocks const mockIsolationEnvGetById = mock(() => Promise.resolve(null)); @@ -102,6 +106,7 @@ mock.module('../clients/factory', () => ({ })); mock.module('fs/promises', () => ({ + ...fsPromises, readFile: mockReadFile, })); @@ -248,6 +253,11 @@ describe('orchestrator', () => { mockExecFileAsync.mockResolvedValue({ stdout: 'main', stderr: '' }); }); + afterEach(() => { + // Restore mock to passthrough mode for other test files + mockReadFile.mockImplementation(originalReadFile); + }); + describe('slash commands (non-invoke)', () => { test('delegates to command handler and returns', async () => { mockHandleCommand.mockResolvedValue({ message: 'Command executed', modified: false }); @@ -712,7 +722,6 @@ describe('orchestrator', () => { 'claude-session-xyz' ); }); - }); describe('stale worktree handling', () => { diff --git a/src/orchestrator/orchestrator.ts b/src/orchestrator/orchestrator.ts index a42b496e..0e8497cf 100644 --- a/src/orchestrator/orchestrator.ts +++ b/src/orchestrator/orchestrator.ts @@ -22,11 +22,7 @@ import { substituteVariables } from '../utils/variable-substitution'; import { classifyAndFormatError } from '../utils/error-formatter'; import { getAssistantClient } from '../clients/factory'; import { getIsolationProvider } from '../isolation'; -import { - worktreeExists, - findWorktreeByBranch, - getCanonicalRepoPath, -} from '../utils/git'; +import { worktreeExists, findWorktreeByBranch, getCanonicalRepoPath } from '../utils/git'; import { cleanupToMakeRoom, getWorktreeStatusBreakdown, diff --git a/src/utils/archon-paths.test.ts b/src/utils/archon-paths.test.ts new file mode 100644 index 00000000..0aa3ad90 --- /dev/null +++ b/src/utils/archon-paths.test.ts @@ -0,0 +1,163 @@ +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; +import { homedir } from 'os'; +import { join } from 'path'; + +import { + isDocker, + getArchonHome, + getArchonWorkspacesPath, + getArchonWorktreesPath, + getArchonConfigPath, + getCommandFolderSearchPaths, + expandTilde, +} from './archon-paths'; + +describe('archon-paths', () => { + const originalEnv: Record = {}; + const envVars = ['WORKSPACE_PATH', 'WORKTREE_BASE', 'ARCHON_HOME', 'ARCHON_DOCKER', 'HOME']; + + beforeEach(() => { + envVars.forEach(key => { + originalEnv[key] = process.env[key]; + }); + }); + + afterEach(() => { + envVars.forEach(key => { + if (originalEnv[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = originalEnv[key]; + } + }); + }); + + describe('expandTilde', () => { + test('expands ~ to home directory', () => { + expect(expandTilde('~/test')).toBe(join(homedir(), 'test')); + }); + + test('returns path unchanged if no tilde', () => { + expect(expandTilde('/absolute/path')).toBe('/absolute/path'); + }); + }); + + describe('isDocker', () => { + test('returns true when WORKSPACE_PATH is /workspace', () => { + process.env.WORKSPACE_PATH = '/workspace'; + expect(isDocker()).toBe(true); + }); + + test('returns true when HOME=/root and WORKSPACE_PATH set', () => { + process.env.HOME = '/root'; + process.env.WORKSPACE_PATH = '/app/workspace'; + expect(isDocker()).toBe(true); + }); + + test('returns true when ARCHON_DOCKER=true', () => { + delete process.env.WORKSPACE_PATH; + process.env.ARCHON_DOCKER = 'true'; + expect(isDocker()).toBe(true); + }); + + test('returns false for local development', () => { + delete process.env.WORKSPACE_PATH; + delete process.env.ARCHON_DOCKER; + process.env.HOME = homedir(); + expect(isDocker()).toBe(false); + }); + }); + + describe('getArchonHome', () => { + test('returns /.archon in Docker', () => { + process.env.WORKSPACE_PATH = '/workspace'; + expect(getArchonHome()).toBe('/.archon'); + }); + + test('returns ARCHON_HOME when set (local)', () => { + delete process.env.WORKSPACE_PATH; + delete process.env.ARCHON_DOCKER; + process.env.ARCHON_HOME = '/custom/archon'; + expect(getArchonHome()).toBe('/custom/archon'); + }); + + test('expands tilde in ARCHON_HOME', () => { + delete process.env.WORKSPACE_PATH; + delete process.env.ARCHON_DOCKER; + process.env.ARCHON_HOME = '~/my-archon'; + expect(getArchonHome()).toBe(join(homedir(), 'my-archon')); + }); + + test('returns ~/.archon by default (local)', () => { + delete process.env.WORKSPACE_PATH; + delete process.env.ARCHON_HOME; + delete process.env.ARCHON_DOCKER; + expect(getArchonHome()).toBe(join(homedir(), '.archon')); + }); + }); + + describe('getArchonWorkspacesPath', () => { + test('returns ~/.archon/workspaces by default', () => { + delete process.env.WORKSPACE_PATH; + delete process.env.ARCHON_HOME; + delete process.env.ARCHON_DOCKER; + expect(getArchonWorkspacesPath()).toBe(join(homedir(), '.archon', 'workspaces')); + }); + + test('returns /.archon/workspaces in Docker', () => { + process.env.ARCHON_DOCKER = 'true'; + expect(getArchonWorkspacesPath()).toBe('/.archon/workspaces'); + }); + + test('uses ARCHON_HOME when set', () => { + delete process.env.WORKSPACE_PATH; + delete process.env.ARCHON_DOCKER; + process.env.ARCHON_HOME = '/custom/archon'; + expect(getArchonWorkspacesPath()).toBe('/custom/archon/workspaces'); + }); + }); + + describe('getArchonWorktreesPath', () => { + test('returns ~/.archon/worktrees by default', () => { + delete process.env.WORKSPACE_PATH; + delete process.env.WORKTREE_BASE; + delete process.env.ARCHON_HOME; + delete process.env.ARCHON_DOCKER; + expect(getArchonWorktreesPath()).toBe(join(homedir(), '.archon', 'worktrees')); + }); + + test('returns /.archon/worktrees in Docker', () => { + process.env.ARCHON_DOCKER = 'true'; + expect(getArchonWorktreesPath()).toBe('/.archon/worktrees'); + }); + + test('uses ARCHON_HOME when set', () => { + delete process.env.WORKSPACE_PATH; + delete process.env.WORKTREE_BASE; + delete process.env.ARCHON_DOCKER; + process.env.ARCHON_HOME = '/custom/archon'; + expect(getArchonWorktreesPath()).toBe('/custom/archon/worktrees'); + }); + }); + + describe('getCommandFolderSearchPaths', () => { + test('returns folders in priority order', () => { + const paths = getCommandFolderSearchPaths(); + expect(paths).toEqual(['.archon/commands', '.claude/commands', '.agents/commands']); + }); + + test('.archon/commands has highest priority', () => { + const paths = getCommandFolderSearchPaths(); + expect(paths[0]).toBe('.archon/commands'); + }); + }); + + describe('getArchonConfigPath', () => { + test('returns path to config.yaml', () => { + delete process.env.WORKSPACE_PATH; + delete process.env.ARCHON_HOME; + delete process.env.ARCHON_DOCKER; + expect(getArchonConfigPath()).toBe(join(homedir(), '.archon', 'config.yaml')); + }); + }); +}); diff --git a/src/utils/archon-paths.ts b/src/utils/archon-paths.ts new file mode 100644 index 00000000..5ad9a500 --- /dev/null +++ b/src/utils/archon-paths.ts @@ -0,0 +1,106 @@ +/** + * Archon path resolution utilities + * + * Directory structure: + * ~/.archon/ # User-level (ARCHON_HOME) + * ├── workspaces/ # Cloned repositories + * ├── worktrees/ # Git worktrees + * └── config.yaml # Global config + * + * For Docker: /.archon/ + */ + +import { join } from 'path'; +import { homedir } from 'os'; + +/** + * Expand ~ to home directory + */ +export function expandTilde(path: string): string { + if (path.startsWith('~')) { + const pathAfterTilde = path.slice(1).replace(/^[/\\]/, ''); + return join(homedir(), pathAfterTilde); + } + return path; +} + +/** + * Detect if running in Docker container + */ +export function isDocker(): boolean { + return ( + process.env.WORKSPACE_PATH === '/workspace' || + (process.env.HOME === '/root' && Boolean(process.env.WORKSPACE_PATH)) || + process.env.ARCHON_DOCKER === 'true' + ); +} + +/** + * Get the Archon home directory + * - Docker: /.archon + * - Local: ~/.archon (or ARCHON_HOME env var) + */ +export function getArchonHome(): string { + if (isDocker()) { + return '/.archon'; + } + + const envHome = process.env.ARCHON_HOME; + if (envHome) { + return expandTilde(envHome); + } + + return join(homedir(), '.archon'); +} + +/** + * Get the workspaces directory (where repos are cloned) + */ +export function getArchonWorkspacesPath(): string { + return join(getArchonHome(), 'workspaces'); +} + +/** + * Get the worktrees directory (where git worktrees are created) + */ +export function getArchonWorktreesPath(): string { + return join(getArchonHome(), 'worktrees'); +} + +/** + * Get the global config file path + */ +export function getArchonConfigPath(): string { + return join(getArchonHome(), 'config.yaml'); +} + +/** + * Get command folder search paths for a repository + * Returns folders in priority order (first match wins) + */ +export function getCommandFolderSearchPaths(): string[] { + return ['.archon/commands', '.claude/commands', '.agents/commands']; +} + +/** + * Get workflow folder search paths for a repository (future) + */ +export function getWorkflowFolderSearchPaths(): string[] { + return ['.archon/workflows', '.claude/workflows', '.agents/workflows']; +} + +/** + * Log the Archon paths configuration (for startup) + */ +export function logArchonPaths(): void { + const home = getArchonHome(); + const workspaces = getArchonWorkspacesPath(); + const worktrees = getArchonWorktreesPath(); + const config = getArchonConfigPath(); + + console.log('[Archon] Paths configured:'); + console.log(` Home: ${home}`); + console.log(` Workspaces: ${workspaces}`); + console.log(` Worktrees: ${worktrees}`); + console.log(` Config: ${config}`); +} diff --git a/src/utils/git.test.ts b/src/utils/git.test.ts index 7ee02396..64d12338 100644 --- a/src/utils/git.test.ts +++ b/src/utils/git.test.ts @@ -73,6 +73,8 @@ describe('git utilities', () => { const originalEnv = process.env.WORKTREE_BASE; const originalWorkspacePath = process.env.WORKSPACE_PATH; const originalHome = process.env.HOME; + const originalArchonHome = process.env.ARCHON_HOME; + const originalArchonDocker = process.env.ARCHON_DOCKER; afterEach(() => { if (originalEnv === undefined) { @@ -90,61 +92,61 @@ describe('git utilities', () => { } else { process.env.HOME = originalHome; } + if (originalArchonHome === undefined) { + delete process.env.ARCHON_HOME; + } else { + process.env.ARCHON_HOME = originalArchonHome; + } + if (originalArchonDocker === undefined) { + delete process.env.ARCHON_DOCKER; + } else { + process.env.ARCHON_DOCKER = originalArchonDocker; + } }); - test('returns ~/tmp/worktrees by default for local (non-Docker)', () => { + test('returns ~/.archon/worktrees by default for local (non-Docker)', () => { delete process.env.WORKTREE_BASE; delete process.env.WORKSPACE_PATH; + delete process.env.ARCHON_HOME; + delete process.env.ARCHON_DOCKER; const result = git.getWorktreeBase('/workspace/my-repo'); - // Default for local: ~/tmp/worktrees (matches worktree-manager skill) - expect(result).toBe(join(homedir(), 'tmp', 'worktrees')); + // Default for local: ~/.archon/worktrees (new Archon structure) + expect(result).toBe(join(homedir(), '.archon', 'worktrees')); }); - test('returns /workspace/worktrees for Docker environment', () => { + test('returns /.archon/worktrees for Docker environment', () => { delete process.env.WORKTREE_BASE; + delete process.env.ARCHON_HOME; process.env.WORKSPACE_PATH = '/workspace'; const result = git.getWorktreeBase('/workspace/my-repo'); - // Docker: inside mounted volume - expect(result).toBe('/workspace/worktrees'); + // Docker: inside /.archon volume + expect(result).toBe('/.archon/worktrees'); }); test('detects Docker by HOME=/root + WORKSPACE_PATH', () => { delete process.env.WORKTREE_BASE; + delete process.env.ARCHON_HOME; + delete process.env.ARCHON_DOCKER; process.env.HOME = '/root'; process.env.WORKSPACE_PATH = '/app/workspace'; const result = git.getWorktreeBase('/workspace/my-repo'); - expect(result).toBe('/workspace/worktrees'); + expect(result).toBe('/.archon/worktrees'); }); - test('uses WORKTREE_BASE for local (non-Docker)', () => { - delete process.env.WORKSPACE_PATH; // Ensure not Docker - delete process.env.HOME; // Reset HOME to actual value - process.env.WORKTREE_BASE = '/custom/worktrees'; + test('uses ARCHON_HOME for local (non-Docker)', () => { + delete process.env.WORKSPACE_PATH; + delete process.env.WORKTREE_BASE; + delete process.env.ARCHON_DOCKER; + process.env.ARCHON_HOME = '/custom/archon'; const result = git.getWorktreeBase('/workspace/my-repo'); - expect(result).toBe('/custom/worktrees'); + expect(result).toBe('/custom/archon/worktrees'); }); - test('ignores WORKTREE_BASE in Docker (end user protection)', () => { - process.env.WORKTREE_BASE = '/custom/worktrees'; - process.env.WORKSPACE_PATH = '/workspace'; // Docker flag + test('uses fixed path in Docker', () => { + delete process.env.ARCHON_HOME; + process.env.ARCHON_DOCKER = 'true'; const result = git.getWorktreeBase('/workspace/my-repo'); - // Docker ALWAYS uses fixed location, override IGNORED - expect(result).toBe('/workspace/worktrees'); - }); - - test('expands tilde in WORKTREE_BASE (local only)', () => { - delete process.env.WORKSPACE_PATH; // Ensure not Docker - process.env.WORKTREE_BASE = '~/tmp/worktrees'; - const result = git.getWorktreeBase('/workspace/my-repo'); - expect(result).toBe(join(homedir(), 'tmp', 'worktrees')); - }); - - test('ignores WORKTREE_BASE with tilde in Docker', () => { - process.env.WORKSPACE_PATH = '/workspace'; // Docker flag - process.env.WORKTREE_BASE = '~/custom/worktrees'; - const result = git.getWorktreeBase('/workspace/my-repo'); - // Tilde never expanded in Docker because override is ignored entirely - expect(result).toBe('/workspace/worktrees'); + expect(result).toBe('/.archon/worktrees'); }); }); diff --git a/src/utils/git.ts b/src/utils/git.ts index d9918af7..a0f13ab2 100644 --- a/src/utils/git.ts +++ b/src/utils/git.ts @@ -2,7 +2,7 @@ import { readFile, access, mkdir as fsMkdir } from 'fs/promises'; import { execFile } from 'child_process'; import { promisify } from 'util'; import { join, basename } from 'path'; -import { homedir } from 'os'; +import { getArchonWorktreesPath } from './archon-paths'; const promisifiedExecFile = promisify(execFile); @@ -27,38 +27,10 @@ export async function mkdirAsync(path: string, options?: { recursive?: boolean } /** * Get the base directory for worktrees - * - Docker: FIXED at /workspace/worktrees (end users can't override) - * - Local: ~/tmp/worktrees by default, WORKTREE_BASE env var to override + * Now delegates to archon-paths module for consistency */ export function getWorktreeBase(_repoPath: string): string { - // 1. Docker: FIXED location, no override for end users - const isDocker = - process.env.WORKSPACE_PATH === '/workspace' || - (process.env.HOME === '/root' && process.env.WORKSPACE_PATH); - - if (isDocker) { - return '/workspace/worktrees'; - } - - // 2. Local: Check WORKTREE_BASE override (for developers with custom setups) - const envBase = process.env.WORKTREE_BASE; - if (envBase) { - return expandTilde(envBase); - } - - // 3. Local default: matches worktree-manager skill - return join(homedir(), 'tmp', 'worktrees'); -} - -/** - * Expand ~ to home directory - */ -function expandTilde(path: string): string { - if (path.startsWith('~')) { - const pathAfterTilde = path.slice(1).replace(/^[/\\]/, ''); - return join(homedir(), pathAfterTilde); - } - return path; + return getArchonWorktreesPath(); } /** diff --git a/src/utils/path-validation.test.ts b/src/utils/path-validation.test.ts index e4cad4cf..64b6d9ad 100644 --- a/src/utils/path-validation.test.ts +++ b/src/utils/path-validation.test.ts @@ -6,54 +6,72 @@ */ import { describe, test, expect, beforeEach, afterAll } from 'bun:test'; -import { resolve } from 'path'; +import { resolve, join } from 'path'; +import { homedir } from 'os'; // Helper to import fresh module with cleared cache async function importFresh() { // Clear the module from cache by deleting it from Loader registry const modulePath = require.resolve('./path-validation'); + const archonPathsModulePath = require.resolve('./archon-paths'); delete require.cache[modulePath]; + delete require.cache[archonPathsModulePath]; return import('./path-validation'); } +// Default archon workspaces path +function getDefaultWorkspacesPath(): string { + return join(homedir(), '.archon', 'workspaces'); +} + describe('path-validation', () => { const originalWorkspacePath = process.env.WORKSPACE_PATH; + const originalArchonHome = process.env.ARCHON_HOME; beforeEach(() => { - // Reset to default /workspace for consistent test behavior + // Reset to default for consistent test behavior delete process.env.WORKSPACE_PATH; + delete process.env.ARCHON_HOME; }); afterAll(() => { - // Restore original env var + // Restore original env vars if (originalWorkspacePath !== undefined) { process.env.WORKSPACE_PATH = originalWorkspacePath; } else { delete process.env.WORKSPACE_PATH; } + if (originalArchonHome !== undefined) { + process.env.ARCHON_HOME = originalArchonHome; + } else { + delete process.env.ARCHON_HOME; + } }); describe('isPathWithinWorkspace', () => { - test('should allow paths within /workspace (default)', async () => { + test('should allow paths within default archon workspaces', async () => { const { isPathWithinWorkspace } = await importFresh(); - expect(isPathWithinWorkspace('/workspace/repo')).toBe(true); - expect(isPathWithinWorkspace('/workspace/repo/src')).toBe(true); - expect(isPathWithinWorkspace('/workspace')).toBe(true); + const defaultPath = getDefaultWorkspacesPath(); + expect(isPathWithinWorkspace(`${defaultPath}/repo`)).toBe(true); + expect(isPathWithinWorkspace(`${defaultPath}/repo/src`)).toBe(true); + expect(isPathWithinWorkspace(defaultPath)).toBe(true); }); test('should allow relative paths that resolve within workspace', async () => { const { isPathWithinWorkspace } = await importFresh(); - expect(isPathWithinWorkspace('repo', '/workspace')).toBe(true); - expect(isPathWithinWorkspace('./repo', '/workspace')).toBe(true); - expect(isPathWithinWorkspace('repo/src/file.ts', '/workspace')).toBe(true); + const defaultPath = getDefaultWorkspacesPath(); + expect(isPathWithinWorkspace('repo', defaultPath)).toBe(true); + expect(isPathWithinWorkspace('./repo', defaultPath)).toBe(true); + expect(isPathWithinWorkspace('repo/src/file.ts', defaultPath)).toBe(true); }); test('should reject path traversal attempts', async () => { const { isPathWithinWorkspace } = await importFresh(); - expect(isPathWithinWorkspace('/workspace/../etc/passwd')).toBe(false); - expect(isPathWithinWorkspace('../etc/passwd', '/workspace')).toBe(false); - expect(isPathWithinWorkspace('/workspace/repo/../../etc/passwd')).toBe(false); - expect(isPathWithinWorkspace('foo/../../../etc/passwd', '/workspace')).toBe(false); + const defaultPath = getDefaultWorkspacesPath(); + expect(isPathWithinWorkspace(`${defaultPath}/../etc/passwd`)).toBe(false); + expect(isPathWithinWorkspace('../etc/passwd', defaultPath)).toBe(false); + expect(isPathWithinWorkspace(`${defaultPath}/repo/../../etc/passwd`)).toBe(false); + expect(isPathWithinWorkspace('foo/../../../etc/passwd', defaultPath)).toBe(false); }); test('should reject paths outside workspace', async () => { @@ -65,62 +83,63 @@ describe('path-validation', () => { test('should reject paths that look similar but are outside workspace', async () => { const { isPathWithinWorkspace } = await importFresh(); - expect(isPathWithinWorkspace('/workspace-other')).toBe(false); - expect(isPathWithinWorkspace('/workspaces')).toBe(false); - expect(isPathWithinWorkspace('/workspace_backup')).toBe(false); + const defaultPath = getDefaultWorkspacesPath(); + expect(isPathWithinWorkspace(`${defaultPath}-other`)).toBe(false); }); - test('should use WORKSPACE_PATH env var when set', async () => { - process.env.WORKSPACE_PATH = '/custom/path'; + test('should use ARCHON_HOME env var when set', async () => { + process.env.ARCHON_HOME = '/custom/archon'; const { isPathWithinWorkspace } = await importFresh(); - expect(isPathWithinWorkspace('/custom/path/repo')).toBe(true); - expect(isPathWithinWorkspace('/workspace/repo')).toBe(false); // Original path now rejected + expect(isPathWithinWorkspace('/custom/archon/workspaces/repo')).toBe(true); + const defaultPath = getDefaultWorkspacesPath(); + expect(isPathWithinWorkspace(`${defaultPath}/repo`)).toBe(false); // Default path now rejected }); }); describe('validateAndResolvePath', () => { test('should return resolved path for valid paths', async () => { const { validateAndResolvePath } = await importFresh(); - // Use resolve() for platform-specific paths - expect(validateAndResolvePath('/workspace/repo')).toBe(resolve('/workspace/repo')); - expect(validateAndResolvePath('repo', '/workspace')).toBe(resolve('/workspace/repo')); - expect(validateAndResolvePath('./src', '/workspace/repo')).toBe( - resolve('/workspace/repo/src') + const defaultPath = getDefaultWorkspacesPath(); + expect(validateAndResolvePath(`${defaultPath}/repo`)).toBe(resolve(`${defaultPath}/repo`)); + expect(validateAndResolvePath('repo', defaultPath)).toBe(resolve(`${defaultPath}/repo`)); + expect(validateAndResolvePath('./src', `${defaultPath}/repo`)).toBe( + resolve(`${defaultPath}/repo/src`) ); }); test('should throw for path traversal attempts', async () => { const { validateAndResolvePath } = await importFresh(); - const workspaceRoot = resolve('/workspace'); - expect(() => validateAndResolvePath('../etc/passwd', '/workspace')).toThrow( - `Path must be within ${workspaceRoot} directory` + const defaultPath = getDefaultWorkspacesPath(); + expect(() => validateAndResolvePath('../etc/passwd', defaultPath)).toThrow( + `Path must be within ${defaultPath} directory` ); - expect(() => validateAndResolvePath('/workspace/../etc/passwd')).toThrow( - `Path must be within ${workspaceRoot} directory` + expect(() => validateAndResolvePath(`${defaultPath}/../etc/passwd`)).toThrow( + `Path must be within ${defaultPath} directory` ); }); test('should throw for paths outside workspace', async () => { const { validateAndResolvePath } = await importFresh(); - const workspaceRoot = resolve('/workspace'); + const defaultPath = getDefaultWorkspacesPath(); expect(() => validateAndResolvePath('/etc/passwd')).toThrow( - `Path must be within ${workspaceRoot} directory` + `Path must be within ${defaultPath} directory` ); expect(() => validateAndResolvePath('/tmp/evil')).toThrow( - `Path must be within ${workspaceRoot} directory` + `Path must be within ${defaultPath} directory` ); }); - test('should use custom WORKSPACE_PATH for validation and error message', async () => { - process.env.WORKSPACE_PATH = '/my/custom/workspace'; + test('should use custom ARCHON_HOME for validation and error message', async () => { + process.env.ARCHON_HOME = '/my/custom/archon'; const { validateAndResolvePath } = await importFresh(); - const customWorkspace = resolve('/my/custom/workspace'); + const customWorkspace = resolve('/my/custom/archon/workspaces'); // Valid path under custom workspace - expect(validateAndResolvePath('/my/custom/workspace/repo')).toBe( - resolve('/my/custom/workspace/repo') + expect(validateAndResolvePath('/my/custom/archon/workspaces/repo')).toBe( + resolve('/my/custom/archon/workspaces/repo') ); // Path under default workspace should now throw with custom workspace in message - expect(() => validateAndResolvePath('/workspace/repo')).toThrow( + const defaultPath = getDefaultWorkspacesPath(); + expect(() => validateAndResolvePath(`${defaultPath}/repo`)).toThrow( `Path must be within ${customWorkspace} directory` ); }); diff --git a/src/utils/path-validation.ts b/src/utils/path-validation.ts index a43fa177..de94eba9 100644 --- a/src/utils/path-validation.ts +++ b/src/utils/path-validation.ts @@ -2,29 +2,26 @@ * Path validation utilities to prevent path traversal attacks */ import { resolve, sep } from 'path'; +import { getArchonWorkspacesPath } from './archon-paths'; -// resolve() converts relative paths to absolute (cross-platform) -const WORKSPACE_ROOT = resolve(process.env.WORKSPACE_PATH ?? '/workspace'); +// Lazy evaluation to allow tests to modify env vars +function getWorkspaceRoot(): string { + return resolve(getArchonWorkspacesPath()); +} /** * Validates that a resolved path stays within the allowed workspace directory. * Prevents path traversal attacks using sequences like "../" * * @param targetPath - The path to validate (can be absolute or relative) - * @param basePath - Optional base path to resolve relative paths against (defaults to /workspace) + * @param basePath - Optional base path to resolve relative paths against (defaults to workspace root) * @returns true if path is within workspace, false otherwise */ -export function isPathWithinWorkspace( - targetPath: string, - basePath: string = WORKSPACE_ROOT -): boolean { - // Resolve to absolute path - const resolvedTarget = resolve(basePath, targetPath); - const resolvedWorkspace = resolve(WORKSPACE_ROOT); - - // Check if resolved path starts with workspace root - // Use trailing separator to prevent matching /workspace-other - return resolvedTarget === resolvedWorkspace || resolvedTarget.startsWith(resolvedWorkspace + sep); +export function isPathWithinWorkspace(targetPath: string, basePath?: string): boolean { + const workspaceRoot = getWorkspaceRoot(); + const effectiveBase = basePath ?? workspaceRoot; + const resolvedTarget = resolve(effectiveBase, targetPath); + return resolvedTarget === workspaceRoot || resolvedTarget.startsWith(workspaceRoot + sep); } /** @@ -36,14 +33,13 @@ export function isPathWithinWorkspace( * @returns The resolved absolute path * @throws Error if path is outside workspace */ -export function validateAndResolvePath( - targetPath: string, - basePath: string = WORKSPACE_ROOT -): string { - const resolvedPath = resolve(basePath, targetPath); +export function validateAndResolvePath(targetPath: string, basePath?: string): string { + const workspaceRoot = getWorkspaceRoot(); + const effectiveBase = basePath ?? workspaceRoot; + const resolvedPath = resolve(effectiveBase, targetPath); if (!isPathWithinWorkspace(resolvedPath)) { - throw new Error(`Path must be within ${WORKSPACE_ROOT} directory`); + throw new Error(`Path must be within ${workspaceRoot} directory`); } return resolvedPath;