diff --git a/README.md b/README.md index a3dd60c8..06555dee 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,9 @@ bunx allagents allagents workspace init my-workspace cd my-workspace +# Or initialize from a remote GitHub template +allagents workspace init my-workspace --from owner/repo/path/to/template + # Add a marketplace (or let auto-registration handle it) allagents plugin marketplace add anthropics/claude-plugins-official @@ -71,6 +74,23 @@ allagents workspace plugin add my-plugin@someuser/their-repo allagents workspace sync ``` +### Initialize from Remote Template + +Start a new workspace instantly from any GitHub repository containing a `workspace.yaml`: + +```bash +# From GitHub URL +allagents workspace init ~/my-project --from https://github.com/myorg/templates/tree/main/nodejs + +# From shorthand +allagents workspace init ~/my-project --from myorg/templates/nodejs + +# From repo root (looks for .allagents/workspace.yaml or workspace.yaml) +allagents workspace init ~/my-project --from myorg/templates +``` + +This fetches the workspace configuration directly from GitHub - no cloning required. + ## Commands ### Workspace Commands @@ -78,6 +98,7 @@ allagents workspace sync ```bash # Initialize a new workspace from template allagents workspace init +allagents workspace init --from # From local path or GitHub URL # Sync all plugins to workspace (non-destructive) allagents workspace sync [options] diff --git a/docs/src/content/docs/getting-started/quick-start.mdx b/docs/src/content/docs/getting-started/quick-start.mdx index e82b6847..16fc25a4 100644 --- a/docs/src/content/docs/getting-started/quick-start.mdx +++ b/docs/src/content/docs/getting-started/quick-start.mdx @@ -12,6 +12,20 @@ allagents workspace init my-workspace cd my-workspace ``` +### Or Start from a Remote Template + +Initialize directly from any GitHub repository - no cloning required: + +```bash +# From a GitHub URL +allagents workspace init my-workspace --from https://github.com/myorg/templates/tree/main/nodejs + +# Or using shorthand +allagents workspace init my-workspace --from myorg/templates/nodejs +``` + +This fetches the workspace configuration directly from GitHub and sets up your workspace instantly. + ## Add Plugins ```bash diff --git a/docs/src/content/docs/guides/workspaces.mdx b/docs/src/content/docs/guides/workspaces.mdx index b9e8ad03..5b669522 100644 --- a/docs/src/content/docs/guides/workspaces.mdx +++ b/docs/src/content/docs/guides/workspaces.mdx @@ -35,10 +35,27 @@ If your template only has `AGENTS.md` and `claude` is in your clients list, AllA # Create from default template allagents workspace init my-workspace -# Create from existing template +# Create from local template allagents workspace init my-workspace --from ./path/to/template + +# Create from remote GitHub template +allagents workspace init my-workspace --from https://github.com/myorg/templates/tree/main/nodejs +allagents workspace init my-workspace --from myorg/templates/nodejs # shorthand ``` +### Remote Templates + +You can initialize a workspace directly from any GitHub repository containing a `workspace.yaml` file. AllAgents will: + +1. Fetch the `workspace.yaml` from `.allagents/workspace.yaml` or `workspace.yaml` in the target path +2. Convert relative `workspace.source` paths to GitHub URLs so sync works +3. Copy any `AGENTS.md` and `CLAUDE.md` files from the template source + +Supported formats: +- Full URL: `https://github.com/owner/repo/tree/branch/path` +- GitHub shorthand: `owner/repo/path` +- Simple repo: `owner/repo` (looks for workspace.yaml in root) + ## workspace.yaml ```yaml diff --git a/docs/src/content/docs/reference/cli.mdx b/docs/src/content/docs/reference/cli.mdx index 69f92a4e..91823b53 100644 --- a/docs/src/content/docs/reference/cli.mdx +++ b/docs/src/content/docs/reference/cli.mdx @@ -6,13 +6,28 @@ description: Complete reference for AllAgents CLI commands. ## Workspace Commands ```bash -allagents workspace init +allagents workspace init [--from ] allagents workspace sync [--force] [--dry-run] allagents workspace status allagents workspace plugin add allagents workspace plugin remove ``` +### workspace init + +Initialize a new workspace from a template: + +| Flag | Description | +|------|-------------| +| `--from ` | Copy workspace.yaml from local path or GitHub URL | + +**Source formats:** +- Local path: `./path/to/template` or `/absolute/path` +- GitHub URL: `https://github.com/owner/repo/tree/branch/path` +- GitHub shorthand: `owner/repo/path` or `owner/repo` + +When using a GitHub source, AllAgents fetches `workspace.yaml` from `.allagents/workspace.yaml` or `workspace.yaml` in the target path. + ### workspace sync Syncs plugins to the workspace using non-destructive sync: diff --git a/package.json b/package.json index d13c14c8..453590dd 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "docs:install": "bun install --cwd docs", "docs:build": "bun run --cwd docs build", "dev": "bun run src/cli/index.ts", - "test": "bun test tests/unit/validators tests/unit/utils tests/unit/models && bun test tests/unit/core/marketplace.test.ts tests/unit/core/sync.test.ts tests/unit/core/transform-glob.test.ts && bun test tests/unit/core/plugin.test.ts", + "test": "bun test tests/unit/validators tests/unit/utils tests/unit/models && bun test tests/unit/core/marketplace.test.ts tests/unit/core/sync.test.ts tests/unit/core/transform-glob.test.ts tests/unit/core/github-fetch.test.ts && bun test tests/unit/core/plugin.test.ts", "test:watch": "bun test --watch", "test:coverage": "bun test --coverage", "test:integration": "bats tests/integration/*.bats", diff --git a/src/core/github-fetch.ts b/src/core/github-fetch.ts new file mode 100644 index 00000000..c14493be --- /dev/null +++ b/src/core/github-fetch.ts @@ -0,0 +1,144 @@ +import { execa } from 'execa'; +import { parseGitHubUrl } from '../utils/plugin-path.js'; +import { CONFIG_DIR, WORKSPACE_CONFIG_FILE } from '../constants.js'; + +/** + * Result of fetching workspace from GitHub + */ +export interface FetchWorkspaceResult { + success: boolean; + content?: string; + error?: string; +} + +/** + * Fetch a single file from GitHub using the gh CLI + * @param owner - Repository owner + * @param repo - Repository name + * @param path - File path within the repository + * @returns File content or null if not found + */ +async function fetchFileFromGitHub( + owner: string, + repo: string, + path: string, +): Promise { + try { + // Use gh api to fetch file contents + // The API returns base64 encoded content + const result = await execa('gh', [ + 'api', + `repos/${owner}/${repo}/contents/${path}`, + '--jq', + '.content', + ]); + + if (result.stdout) { + // Decode base64 content + const content = Buffer.from(result.stdout, 'base64').toString('utf-8'); + return content; + } + return null; + } catch { + return null; + } +} + +/** + * Fetch workspace.yaml from a GitHub URL + * + * Supports: + * - https://github.com/owner/repo (looks for .allagents/workspace.yaml or workspace.yaml) + * - https://github.com/owner/repo/tree/branch/path (looks in path/.allagents/workspace.yaml or path/workspace.yaml) + * - owner/repo (shorthand) + * - owner/repo/path/to/workspace (shorthand with subpath) + * + * @param url - GitHub URL or shorthand + * @returns Result with workspace.yaml content or error + */ +export async function fetchWorkspaceFromGitHub( + url: string, +): Promise { + const parsed = parseGitHubUrl(url); + if (!parsed) { + return { + success: false, + error: 'Invalid GitHub URL format. Expected: https://github.com/owner/repo', + }; + } + + const { owner, repo, subpath } = parsed; + + // Check if gh CLI is available + try { + await execa('gh', ['--version']); + } catch { + return { + success: false, + error: 'gh CLI not installed. Install from: https://cli.github.com', + }; + } + + // Check if repository exists + try { + await execa('gh', ['repo', 'view', `${owner}/${repo}`, '--json', 'name']); + } catch (error) { + if (error instanceof Error) { + const errorMessage = error.message.toLowerCase(); + if ( + errorMessage.includes('not found') || + errorMessage.includes('404') || + errorMessage.includes('could not resolve to a repository') + ) { + return { + success: false, + error: `Repository not found: ${owner}/${repo}`, + }; + } + if ( + errorMessage.includes('auth') || + errorMessage.includes('authentication') + ) { + return { + success: false, + error: 'GitHub authentication required. Run: gh auth login', + }; + } + } + return { + success: false, + error: `Failed to access repository: ${error instanceof Error ? error.message : String(error)}`, + }; + } + + // Determine the base path to look for workspace.yaml + const basePath = subpath || ''; + + // Try to find workspace.yaml in order of preference: + // 1. {basePath}/.allagents/workspace.yaml + // 2. {basePath}/workspace.yaml + const pathsToTry = basePath + ? [ + `${basePath}/${CONFIG_DIR}/${WORKSPACE_CONFIG_FILE}`, + `${basePath}/${WORKSPACE_CONFIG_FILE}`, + ] + : [ + `${CONFIG_DIR}/${WORKSPACE_CONFIG_FILE}`, + WORKSPACE_CONFIG_FILE, + ]; + + for (const path of pathsToTry) { + const content = await fetchFileFromGitHub(owner, repo, path); + if (content) { + return { + success: true, + content, + }; + } + } + + return { + success: false, + error: `No workspace.yaml found in: ${owner}/${repo}${subpath ? `/${subpath}` : ''}\n Expected at: ${pathsToTry.join(' or ')}`, + }; +} diff --git a/src/core/workspace.ts b/src/core/workspace.ts index 3e556671..a5ce8082 100644 --- a/src/core/workspace.ts +++ b/src/core/workspace.ts @@ -6,7 +6,8 @@ import { load, dump } from 'js-yaml'; import { syncWorkspace, type SyncResult } from './sync.js'; import { ensureWorkspaceRules } from './transform.js'; import { CONFIG_DIR, WORKSPACE_CONFIG_FILE, AGENT_FILES } from '../constants.js'; -import { isGitHubUrl } from '../utils/plugin-path.js'; +import { isGitHubUrl, parseGitHubUrl } from '../utils/plugin-path.js'; +import { fetchWorkspaceFromGitHub } from './github-fetch.js'; /** * Options for workspace initialization @@ -67,65 +68,96 @@ export async function initWorkspace( let sourceDir: string | undefined; if (options.from) { - // Copy workspace.yaml from --from path - const fromPath = resolve(options.from); - - if (!existsSync(fromPath)) { - throw new Error(`Template not found: ${fromPath}`); - } - - // Check if --from is a file or directory - const { stat } = await import('node:fs/promises'); - const fromStat = await stat(fromPath); - - let sourceYamlPath: string; - if (fromStat.isDirectory()) { - // Look for workspace.yaml in .allagents/ subdirectory first, then root - const nestedPath = join(fromPath, CONFIG_DIR, WORKSPACE_CONFIG_FILE); - const rootPath = join(fromPath, WORKSPACE_CONFIG_FILE); - - if (existsSync(nestedPath)) { - sourceYamlPath = nestedPath; - sourceDir = fromPath; // Source dir is the directory containing .allagents/ - } else if (existsSync(rootPath)) { - sourceYamlPath = rootPath; - sourceDir = fromPath; // Source dir is where workspace.yaml lives - } else { - throw new Error( - `No workspace.yaml found in: ${fromPath}\n Expected at: ${nestedPath} or ${rootPath}`, - ); - } - } else { - // --from points directly to a yaml file - sourceYamlPath = fromPath; - // Source dir depends on whether yaml is inside .allagents/ or at workspace root - const parentDir = dirname(fromPath); - if (parentDir.endsWith(CONFIG_DIR)) { - // yaml is in .allagents/, source dir is the workspace root (parent of .allagents/) - sourceDir = dirname(parentDir); - } else { - // yaml is at workspace root, source dir is that directory - sourceDir = parentDir; + // Check if --from is a GitHub URL + if (isGitHubUrl(options.from)) { + const fetchResult = await fetchWorkspaceFromGitHub(options.from); + if (!fetchResult.success || !fetchResult.content) { + throw new Error(fetchResult.error || 'Failed to fetch workspace from GitHub'); } - } - - workspaceYamlContent = await readFile(sourceYamlPath, 'utf-8'); - - // Rewrite relative workspace.source to absolute path so sync works after init - if (sourceDir) { + workspaceYamlContent = fetchResult.content; + // For GitHub sources, keep workspace.source as-is (it's already a URL or relative to the repo) + // We need to rewrite relative workspace.source to the full GitHub URL const parsed = load(workspaceYamlContent) as Record; const workspace = parsed?.workspace as { source?: string } | undefined; if (workspace?.source) { const source = workspace.source; - // Convert relative local paths to absolute (skip URLs and already-absolute paths) + // If workspace.source is a relative path, convert to GitHub URL if (!isGitHubUrl(source) && !isAbsolute(source)) { - workspace.source = resolve(sourceDir, source); - workspaceYamlContent = dump(parsed, { lineWidth: -1 }); + // Build GitHub URL from the --from location plus the relative source + const parsedUrl = parseGitHubUrl(options.from); + if (parsedUrl) { + const basePath = parsedUrl.subpath || ''; + // Remove workspace.yaml from the base path if present + const baseDir = basePath.replace(/\/?\.allagents\/workspace\.yaml$/, '') + .replace(/\/?workspace\.yaml$/, ''); + const sourcePath = baseDir ? `${baseDir}/${source}` : source; + workspace.source = `https://github.com/${parsedUrl.owner}/${parsedUrl.repo}/tree/main/${sourcePath}`; + workspaceYamlContent = dump(parsed, { lineWidth: -1 }); + } } } - } + console.log(`✓ Using workspace.yaml from: ${options.from}`); + } else { + // Copy workspace.yaml from local --from path + const fromPath = resolve(options.from); - console.log(`✓ Using workspace.yaml from: ${sourceYamlPath}`); + if (!existsSync(fromPath)) { + throw new Error(`Template not found: ${fromPath}`); + } + + // Check if --from is a file or directory + const { stat } = await import('node:fs/promises'); + const fromStat = await stat(fromPath); + + let sourceYamlPath: string; + if (fromStat.isDirectory()) { + // Look for workspace.yaml in .allagents/ subdirectory first, then root + const nestedPath = join(fromPath, CONFIG_DIR, WORKSPACE_CONFIG_FILE); + const rootPath = join(fromPath, WORKSPACE_CONFIG_FILE); + + if (existsSync(nestedPath)) { + sourceYamlPath = nestedPath; + sourceDir = fromPath; // Source dir is the directory containing .allagents/ + } else if (existsSync(rootPath)) { + sourceYamlPath = rootPath; + sourceDir = fromPath; // Source dir is where workspace.yaml lives + } else { + throw new Error( + `No workspace.yaml found in: ${fromPath}\n Expected at: ${nestedPath} or ${rootPath}`, + ); + } + } else { + // --from points directly to a yaml file + sourceYamlPath = fromPath; + // Source dir depends on whether yaml is inside .allagents/ or at workspace root + const parentDir = dirname(fromPath); + if (parentDir.endsWith(CONFIG_DIR)) { + // yaml is in .allagents/, source dir is the workspace root (parent of .allagents/) + sourceDir = dirname(parentDir); + } else { + // yaml is at workspace root, source dir is that directory + sourceDir = parentDir; + } + } + + workspaceYamlContent = await readFile(sourceYamlPath, 'utf-8'); + + // Rewrite relative workspace.source to absolute path so sync works after init + if (sourceDir) { + const parsed = load(workspaceYamlContent) as Record; + const workspace = parsed?.workspace as { source?: string } | undefined; + if (workspace?.source) { + const source = workspace.source; + // Convert relative local paths to absolute (skip URLs and already-absolute paths) + if (!isGitHubUrl(source) && !isAbsolute(source)) { + workspace.source = resolve(sourceDir, source); + workspaceYamlContent = dump(parsed, { lineWidth: -1 }); + } + } + } + + console.log(`✓ Using workspace.yaml from: ${sourceYamlPath}`); + } } else { // Use default template's workspace.yaml const defaultYamlPath = join(defaultTemplatePath, CONFIG_DIR, WORKSPACE_CONFIG_FILE); diff --git a/tests/unit/core/github-fetch.test.ts b/tests/unit/core/github-fetch.test.ts new file mode 100644 index 00000000..bfe13c1d --- /dev/null +++ b/tests/unit/core/github-fetch.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect, mock, beforeEach } from 'bun:test'; +import { fetchWorkspaceFromGitHub } from '../../../src/core/github-fetch.js'; + +// Mock execa +const execaMock = mock(() => Promise.resolve({ stdout: '', stderr: '' })); +mock.module('execa', () => ({ + execa: execaMock, +})); + +beforeEach(() => { + execaMock.mockClear(); +}); + +describe('fetchWorkspaceFromGitHub', () => { + it('should validate GitHub URL format', async () => { + const result = await fetchWorkspaceFromGitHub('not-a-github-url'); + expect(result.success).toBe(false); + expect(result.error).toContain('Invalid GitHub URL'); + }); + + it('should check for gh CLI availability', async () => { + execaMock.mockRejectedValueOnce(new Error('gh not found')); + + const result = await fetchWorkspaceFromGitHub('https://github.com/owner/repo'); + expect(result.success).toBe(false); + expect(result.error).toContain('gh CLI not installed'); + }); + + it('should handle repository not found', async () => { + execaMock + .mockResolvedValueOnce({ stdout: 'gh version' }) // gh --version + .mockRejectedValueOnce(new Error('404 not found')); // gh repo view + + const result = await fetchWorkspaceFromGitHub('https://github.com/owner/repo'); + expect(result.success).toBe(false); + expect(result.error).toContain('Repository not found'); + }); + + it('should handle authentication errors', async () => { + execaMock + .mockResolvedValueOnce({ stdout: 'gh version' }) // gh --version + .mockRejectedValueOnce(new Error('authentication required')); // gh repo view + + const result = await fetchWorkspaceFromGitHub('https://github.com/owner/repo'); + expect(result.success).toBe(false); + expect(result.error).toContain('authentication required'); + }); + + it('should fetch workspace.yaml from .allagents directory', async () => { + const yamlContent = 'plugins:\n - code-review@official'; + const base64Content = Buffer.from(yamlContent).toString('base64'); + + execaMock + .mockResolvedValueOnce({ stdout: 'gh version' }) // gh --version + .mockResolvedValueOnce({ stdout: '{"name":"repo"}' }) // gh repo view + .mockResolvedValueOnce({ stdout: base64Content }); // gh api contents + + const result = await fetchWorkspaceFromGitHub('https://github.com/owner/repo'); + expect(result.success).toBe(true); + expect(result.content).toBe(yamlContent); + }); + + it('should fallback to root workspace.yaml if .allagents not found', async () => { + const yamlContent = 'plugins:\n - my-plugin@marketplace'; + const base64Content = Buffer.from(yamlContent).toString('base64'); + + execaMock + .mockResolvedValueOnce({ stdout: 'gh version' }) // gh --version + .mockResolvedValueOnce({ stdout: '{"name":"repo"}' }) // gh repo view + .mockRejectedValueOnce(new Error('404')) // .allagents/workspace.yaml not found + .mockResolvedValueOnce({ stdout: base64Content }); // root workspace.yaml + + const result = await fetchWorkspaceFromGitHub('https://github.com/owner/repo'); + expect(result.success).toBe(true); + expect(result.content).toBe(yamlContent); + }); + + it('should handle subpath in GitHub URL', async () => { + const yamlContent = 'clients:\n - claude'; + const base64Content = Buffer.from(yamlContent).toString('base64'); + + execaMock + .mockResolvedValueOnce({ stdout: 'gh version' }) + .mockResolvedValueOnce({ stdout: '{"name":"repo"}' }) + .mockResolvedValueOnce({ stdout: base64Content }); + + const result = await fetchWorkspaceFromGitHub( + 'https://github.com/owner/repo/tree/main/templates/nodejs' + ); + expect(result.success).toBe(true); + expect(result.content).toBe(yamlContent); + }); + + it('should parse different GitHub URL formats', async () => { + const urls = [ + 'https://github.com/owner/repo', + 'github.com/owner/repo', + 'gh:owner/repo', + 'owner/repo', + ]; + + for (const url of urls) { + execaMock.mockClear(); + const yamlContent = 'plugins: []'; + const base64Content = Buffer.from(yamlContent).toString('base64'); + + execaMock + .mockResolvedValueOnce({ stdout: 'gh version' }) + .mockResolvedValueOnce({ stdout: '{"name":"repo"}' }) + .mockResolvedValueOnce({ stdout: base64Content }); + + const result = await fetchWorkspaceFromGitHub(url); + expect(result.success).toBe(true); + expect(result.content).toBe(yamlContent); + } + }); + + it('should return error when no workspace.yaml found', async () => { + execaMock + .mockResolvedValueOnce({ stdout: 'gh version' }) + .mockResolvedValueOnce({ stdout: '{"name":"repo"}' }) + .mockRejectedValueOnce(new Error('404')) // .allagents/workspace.yaml + .mockRejectedValueOnce(new Error('404')); // workspace.yaml + + const result = await fetchWorkspaceFromGitHub('https://github.com/owner/repo'); + expect(result.success).toBe(false); + expect(result.error).toContain('No workspace.yaml found'); + }); +});