all-hands-cli 0.1.7 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -37,42 +37,26 @@ For proprietary domains with documentation pages:
37
37
  - Extract API patterns, configuration examples
38
38
  - Note version-specific behaviors
39
39
 
40
- ### Open Source Inspiration (Clone & Browse)
41
-
42
- For exploring GitHub repositories locally:
43
-
44
- 1. **Search for repositories**:
45
- ```bash
46
- gh search repos "<query>" --limit 5
47
- ```
48
-
49
- 2. **Clone to local research folder**:
50
- ```bash
51
- # Clone into .reposearch folder (gitignored)
52
- mkdir -p .reposearch
53
- git clone --depth 1 <repo-url> .reposearch/<repo-name>
54
- ```
55
-
56
- Note: Ensure `.reposearch/` is in the project's `.gitignore`.
57
-
58
- 3. **Browse locally with standard tooling**:
59
- - Use `Glob` to find files by pattern
60
- - Use `Grep` to search code content
61
- - Use `Read` to examine specific files
62
- - Use `ls` to explore directory structure
63
-
64
- 4. **Clean up when done** (optional):
65
- ```bash
66
- rm -rf .reposearch/<repo-name>
67
- ```
68
-
69
- This approach leverages the agent's superior local file navigation capabilities:
70
- - Full regex search across the codebase
71
- - Fast pattern matching and file discovery
72
- - Direct file reading without API encoding issues
73
- - Study implementation patterns in similar projects
74
- - Extract architectural decisions
75
- - Note how libraries handle similar problems
40
+ ### Open Source Inspiration (`ah spawn reposearch`)
41
+
42
+ Use `ah spawn reposearch` to clone external GitHub repos and delegate research to an AI agent that searches across both the current project and external codebases.
43
+
44
+ **OSS codebase answers** ask how a specific project handles something:
45
+ ```bash
46
+ ah spawn reposearch "How does this project handle authentication?" --repos https://github.com/org/project
47
+ ```
48
+
49
+ **Cross-repo comparison** compare our implementation vs an external project:
50
+ ```bash
51
+ ah spawn reposearch "Compare our error handling approach vs theirs" --repos https://github.com/org/project
52
+ ```
53
+
54
+ **Multi-framework comparison** — check out 2+ repos, compare approaches side-by-side:
55
+ ```bash
56
+ ah spawn reposearch "How do these projects handle routing?" --repos https://github.com/a/repo,https://github.com/b/repo
57
+ ```
58
+
59
+ Re-running the same command with the same repos is fast — repos are cached locally between invocations.
76
60
 
77
61
  ### Parallel Exploration
78
62
 
@@ -3,13 +3,15 @@
3
3
  *
4
4
  * Commands:
5
5
  * ah spawn codesearch "<query>" [--budget <n>] [--steps <n>]
6
+ * ah spawn reposearch "<query>" --repos <url1,url2,...> [--steps <n>]
6
7
  */
7
8
 
8
9
  import { Command } from "commander";
9
- import { readFileSync } from "fs";
10
- import { dirname, join } from "path";
10
+ import { existsSync, mkdirSync, readFileSync } from "fs";
11
+ import { dirname, join, basename } from "path";
11
12
  import { fileURLToPath } from "url";
12
- import { AgentRunner, withDebugInfo } from "../lib/opencode/index.js";
13
+ import { execFileSync } from "child_process";
14
+ import { AgentRunner, withDebugInfo, type ReposearchOutput } from "../lib/opencode/index.js";
13
15
  import { BaseCommand, type CommandResult } from "../lib/base-command.js";
14
16
  import { loadProjectSettings } from "../hooks/shared.js";
15
17
 
@@ -19,15 +21,23 @@ const getProjectRoot = (): string => {
19
21
  return process.env.PROJECT_ROOT || process.cwd();
20
22
  };
21
23
 
22
- // Load prompt
24
+ // Load prompts
23
25
  const CODESEARCH_PROMPT_PATH = join(__dirname, "../lib/opencode/prompts/codesearch.md");
24
26
  const getCodesearchPrompt = (): string => readFileSync(CODESEARCH_PROMPT_PATH, "utf-8");
25
27
 
26
- // Defaults
28
+ const REPOSEARCH_PROMPT_PATH = join(__dirname, "../lib/opencode/prompts/reposearch.md");
29
+ const getReposearchPrompt = (): string => readFileSync(REPOSEARCH_PROMPT_PATH, "utf-8");
30
+
31
+ // Codesearch defaults
27
32
  const DEFAULT_TOOL_BUDGET = 12;
28
33
  const DEFAULT_STEPS_LIMIT = 20;
29
34
  const DEFAULT_TIMEOUT_MS = 120000; // 2 min
30
35
 
36
+ // Reposearch defaults
37
+ const DEFAULT_REPOSEARCH_STEPS = 30;
38
+ const DEFAULT_REPOSEARCH_TIMEOUT_MS = 180000; // 3 min
39
+ const DEFAULT_REPOSEARCH_TOOL_BUDGET = 20;
40
+
31
41
  // Output types
32
42
  interface CodeResult {
33
43
  file: string;
@@ -125,6 +135,165 @@ Respond with JSON matching the required schema.`;
125
135
  }
126
136
  }
127
137
 
138
+ /**
139
+ * Derive a directory name from a GitHub URL.
140
+ * e.g. "https://github.com/org/repo" -> "org--repo"
141
+ */
142
+ function repoDirName(url: string): string {
143
+ try {
144
+ const parsed = new URL(url);
145
+ // Remove .git suffix and leading slash, replace / with --
146
+ const path = parsed.pathname.replace(/\.git$/, "").replace(/^\//, "");
147
+ return path.replace(/\//g, "--");
148
+ } catch {
149
+ // Fallback: use basename-like extraction
150
+ return basename(url).replace(/\.git$/, "") || "repo";
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Clone or pull a repo into the .reposearch directory.
156
+ * Returns the local directory path, or null on failure.
157
+ */
158
+ function cloneOrPullRepo(reposearchDir: string, repoUrl: string): string | null {
159
+ const dirName = repoDirName(repoUrl);
160
+ const repoDir = join(reposearchDir, dirName);
161
+
162
+ try {
163
+ if (existsSync(join(repoDir, ".git"))) {
164
+ // Repo already cloned — pull latest
165
+ execFileSync("git", ["pull", "--ff-only"], {
166
+ cwd: repoDir,
167
+ stdio: "pipe",
168
+ timeout: 60000,
169
+ });
170
+ } else {
171
+ // Fresh clone (shallow)
172
+ mkdirSync(reposearchDir, { recursive: true });
173
+ execFileSync("git", ["clone", "--depth", "1", repoUrl, repoDir], {
174
+ stdio: "pipe",
175
+ timeout: 120000,
176
+ });
177
+ }
178
+ return repoDir;
179
+ } catch (error) {
180
+ const message = error instanceof Error ? error.message : String(error);
181
+ console.error(`Warning: Failed to clone/pull ${repoUrl}: ${message}`);
182
+ return null;
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Reposearch command - spawn research agent that searches across current project and external repos.
188
+ */
189
+ class ReposearchCommand extends BaseCommand {
190
+ readonly name = "reposearch";
191
+ readonly description = "Research code across the current project and external GitHub repositories";
192
+
193
+ defineArguments(cmd: Command): void {
194
+ cmd
195
+ .argument("<query>", "Research query (natural language)")
196
+ .requiredOption("--repos <urls>", "Comma-separated GitHub repo URLs to search")
197
+ .option("--steps <n>", "Hard step limit for agent iterations", String(DEFAULT_REPOSEARCH_STEPS))
198
+ .option("--debug", "Include agent debug metadata (model, timing, fallback) in output");
199
+ }
200
+
201
+ async execute(args: Record<string, unknown>): Promise<CommandResult> {
202
+ const query = args.query as string;
203
+ const reposRaw = args.repos as string;
204
+ const stepsLimit = parseInt((args.steps as string) ?? String(DEFAULT_REPOSEARCH_STEPS), 10);
205
+ const debug = !!args.debug;
206
+
207
+ if (!query) {
208
+ return this.error("validation_error", "query is required");
209
+ }
210
+ if (!reposRaw) {
211
+ return this.error("validation_error", "--repos is required (comma-separated GitHub URLs)");
212
+ }
213
+
214
+ const repoUrls = reposRaw.split(",").map((u) => u.trim()).filter(Boolean);
215
+ if (repoUrls.length === 0) {
216
+ return this.error("validation_error", "No valid repo URLs provided");
217
+ }
218
+
219
+ const projectRoot = getProjectRoot();
220
+ const reposearchDir = join(projectRoot, ".reposearch");
221
+
222
+ // Clone or pull each repo
223
+ const repoDirectories: Array<{ url: string; dir: string }> = [];
224
+ const warnings: string[] = [];
225
+
226
+ for (const url of repoUrls) {
227
+ const dir = cloneOrPullRepo(reposearchDir, url);
228
+ if (dir) {
229
+ repoDirectories.push({ url, dir });
230
+ } else {
231
+ warnings.push(`Failed to clone/pull: ${url}`);
232
+ }
233
+ }
234
+
235
+ if (repoDirectories.length === 0) {
236
+ return this.error("clone_error", "All repo clones/pulls failed. Check URLs and network.");
237
+ }
238
+
239
+ const runner = new AgentRunner(projectRoot);
240
+
241
+ // Build directory listing for the agent
242
+ const repoListing = repoDirectories
243
+ .map((r) => `- ${r.url} → ${r.dir}`)
244
+ .join("\n");
245
+
246
+ const userMessage = `## Research Query
247
+ ${query}
248
+
249
+ ## Directories to Search
250
+
251
+ ### Current Project
252
+ - Root: ${projectRoot}
253
+
254
+ ### External Repositories
255
+ ${repoListing}
256
+
257
+ ## Budget
258
+ - Tool budget (soft): ${DEFAULT_REPOSEARCH_TOOL_BUDGET} tool calls
259
+ - Available tools: grep (text search), glob (file patterns), read (file content), lsp (if available)
260
+ - Search all relevant directories to answer the query
261
+
262
+ ${warnings.length > 0 ? `## Warnings\n${warnings.map((w) => `- ${w}`).join("\n")}\n\n` : ""}Respond with JSON matching the required schema.`;
263
+
264
+ try {
265
+ const result = await runner.run<ReposearchOutput>(
266
+ {
267
+ name: "reposearch",
268
+ systemPrompt: getReposearchPrompt(),
269
+ timeoutMs: DEFAULT_REPOSEARCH_TIMEOUT_MS,
270
+ steps: stepsLimit,
271
+ },
272
+ userMessage
273
+ );
274
+
275
+ if (!result.success) {
276
+ return this.error("agent_error", result.error ?? "Unknown agent error");
277
+ }
278
+
279
+ const data = result.data!;
280
+
281
+ return this.success(withDebugInfo({
282
+ query,
283
+ repos_requested: repoUrls,
284
+ repos_analyzed: data.repos_analyzed,
285
+ analysis: data.analysis,
286
+ code_references: data.code_references,
287
+ warnings,
288
+ metadata: result.metadata,
289
+ }, result, debug));
290
+ } catch (error) {
291
+ const message = error instanceof Error ? error.message : String(error);
292
+ return this.error("spawn_error", message);
293
+ }
294
+ }
295
+ }
296
+
128
297
  /**
129
298
  * Register spawn commands on the given commander program.
130
299
  */
@@ -133,19 +302,35 @@ export function register(program: Command): void {
133
302
  .command("spawn")
134
303
  .description("Spawn sub-agents for specialized tasks");
135
304
 
305
+ // Register codesearch
136
306
  const codesearch = new CodesearchCommand();
137
- const cmd = spawnCmd.command(codesearch.name).description(codesearch.description);
138
- codesearch.defineArguments(cmd);
139
- cmd.action(async (...args) => {
307
+ const codesearchCmd = spawnCmd.command(codesearch.name).description(codesearch.description);
308
+ codesearch.defineArguments(codesearchCmd);
309
+ codesearchCmd.action(async (...args) => {
140
310
  const opts = args[args.length - 2] as Record<string, unknown>;
141
311
  const cmdObj = args[args.length - 1] as Command;
142
312
  const positionalArgs = cmdObj.args;
143
313
 
144
- // Map positional args to named args based on command definition
145
314
  const namedArgs: Record<string, unknown> = { ...opts };
146
315
  if (positionalArgs[0]) namedArgs.query = positionalArgs[0];
147
316
 
148
317
  const result = await codesearch.execute(namedArgs);
149
318
  console.log(JSON.stringify(result, null, 2));
150
319
  });
320
+
321
+ // Register reposearch
322
+ const reposearch = new ReposearchCommand();
323
+ const reposearchCmd = spawnCmd.command(reposearch.name).description(reposearch.description);
324
+ reposearch.defineArguments(reposearchCmd);
325
+ reposearchCmd.action(async (...args) => {
326
+ const opts = args[args.length - 2] as Record<string, unknown>;
327
+ const cmdObj = args[args.length - 1] as Command;
328
+ const positionalArgs = cmdObj.args;
329
+
330
+ const namedArgs: Record<string, unknown> = { ...opts };
331
+ if (positionalArgs[0]) namedArgs.query = positionalArgs[0];
332
+
333
+ const result = await reposearch.execute(namedArgs);
334
+ console.log(JSON.stringify(result, null, 2));
335
+ });
151
336
  }
@@ -101,6 +101,22 @@ export interface AggregatorOutput {
101
101
  design_notes?: string[];
102
102
  }
103
103
 
104
+ // Reposearch output types
105
+ export interface RepoCodeReference {
106
+ repo: string; // "current" or the GitHub URL
107
+ file: string; // relative path within the repo
108
+ line_start: number;
109
+ line_end: number;
110
+ code: string;
111
+ context: string;
112
+ }
113
+
114
+ export interface ReposearchOutput {
115
+ analysis: string; // markdown research findings
116
+ code_references: RepoCodeReference[];
117
+ repos_analyzed: string[];
118
+ }
119
+
104
120
  export { AgentRunner } from "./runner.js";
105
121
 
106
122
  // Debug metadata for agent results (included when --debug flag is passed)
@@ -0,0 +1,115 @@
1
+ # Repo Search Agent
2
+
3
+ You research code across a current project and one or more external GitHub repositories. You search all provided directories to answer questions, compare implementations, and analyze patterns across codebases. Return structured JSON with your findings.
4
+
5
+ ## Context
6
+
7
+ You will receive:
8
+ - **Project root directory**: The current project's codebase
9
+ - **External repo directories**: One or more cloned GitHub repos under `.reposearch/`
10
+ - **Research query**: What to investigate across these codebases
11
+
12
+ ## Available Tools
13
+
14
+ **grep** - Text search via ripgrep
15
+ - Search across any of the provided directories
16
+ - Best for: string literals, identifiers, patterns, comments
17
+
18
+ **glob** - File pattern matching
19
+ - Discover files by extension or name pattern in any repo
20
+ - Scope searches to specific directories
21
+
22
+ **read** - File content retrieval
23
+ - Read specific files from any repo after finding them
24
+ - Specify line ranges when possible to minimize output
25
+
26
+ **lsp** (if available) - Language Server Protocol
27
+ - goToDefinition, findReferences, hover
28
+ - Works on the current project; may not be available for external repos
29
+
30
+ ## Search Strategy
31
+
32
+ 1. **Understand the query**: Determine if it's about a single repo, a comparison, or a pattern search
33
+ 2. **Parallel discovery**: Search relevant directories simultaneously using grep/glob
34
+ 3. **Targeted reads**: Read specific files to understand implementations
35
+ 4. **Cross-reference**: Compare findings between repos to answer the query
36
+ 5. **Synthesize**: Combine findings into a coherent analysis
37
+
38
+ ## Budget Awareness
39
+
40
+ You have a soft tool budget. Stay efficient:
41
+ - Use grep/glob to narrow down before reading files
42
+ - Don't read entire files when a section suffices
43
+ - Avoid redundant searches across repos
44
+ - Focus on the most relevant code to the query
45
+
46
+ ## Output Format
47
+
48
+ Return ONLY valid JSON:
49
+
50
+ ```json
51
+ {
52
+ "analysis": "## Findings\n\nMarkdown analysis of research findings...",
53
+ "code_references": [
54
+ {
55
+ "repo": "current",
56
+ "file": "src/auth/handler.ts",
57
+ "line_start": 10,
58
+ "line_end": 25,
59
+ "code": "function handleAuth() { ... }",
60
+ "context": "Current project's auth handler using JWT"
61
+ },
62
+ {
63
+ "repo": "https://github.com/org/project",
64
+ "file": "lib/auth.py",
65
+ "line_start": 45,
66
+ "line_end": 60,
67
+ "code": "class AuthMiddleware: ...",
68
+ "context": "External project uses middleware-based auth"
69
+ }
70
+ ],
71
+ "repos_analyzed": ["current", "https://github.com/org/project"]
72
+ }
73
+ ```
74
+
75
+ ## Field Guidelines
76
+
77
+ **analysis** (markdown string):
78
+ - Structured markdown answering the research query
79
+ - Include headings, comparisons, and key observations
80
+ - Reference specific code when making claims
81
+ - Keep focused on the query — don't summarize everything
82
+
83
+ **code_references** (array, max 15):
84
+ - `repo`: "current" for the project, or the GitHub URL for external repos
85
+ - `file`: Relative path within the repo
86
+ - `line_start` / `line_end`: 1-indexed line range
87
+ - `code`: Actual code snippet (keep concise, 1-20 lines)
88
+ - `context`: Why this reference is relevant (1 sentence)
89
+
90
+ **repos_analyzed** (array):
91
+ - List of repos that were actually searched
92
+ - "current" for the project root
93
+ - GitHub URLs for external repos
94
+
95
+ ## Use Cases
96
+
97
+ **OSS Q&A**: "How does project X handle authentication?"
98
+ - Focus on the external repo, search for auth-related patterns
99
+ - Provide concrete code examples with explanation
100
+
101
+ **Cross-repo comparison**: "Compare our error handling vs project X"
102
+ - Search both repos for error handling patterns
103
+ - Highlight similarities and differences in the analysis
104
+
105
+ **Multi-framework comparison**: "How do projects A and B handle routing?"
106
+ - Search multiple external repos
107
+ - Compare approaches side-by-side in the analysis
108
+
109
+ ## Anti-patterns
110
+
111
+ - Returning entire files instead of relevant sections
112
+ - Analysis not grounded in actual code found
113
+ - Missing cross-references when comparison was requested
114
+ - Exceeding tool budget with redundant searches
115
+ - Not searching all provided repos when the query requires it
package/bin/sync-cli.js CHANGED
@@ -4905,6 +4905,15 @@ function getGitFiles(repoPath) {
4905
4905
  }
4906
4906
  return files;
4907
4907
  }
4908
+ function getFileBlobHash(filePath, repoPath) {
4909
+ const result = git(["hash-object", filePath], repoPath);
4910
+ return result.success ? result.stdout.trim() : null;
4911
+ }
4912
+ function fileExistsInHistory(relPath, blobHash, repoPath) {
4913
+ const result = git(["rev-list", "HEAD", "--objects", "--", relPath], repoPath);
4914
+ if (!result.success || !result.stdout) return false;
4915
+ return result.stdout.split("\n").some((line) => line.startsWith(blobHash + " "));
4916
+ }
4908
4917
 
4909
4918
  // src/lib/manifest.ts
4910
4919
  import { readFileSync as readFileSync5, existsSync as existsSync3, statSync as statSync3 } from "fs";
@@ -7224,6 +7233,12 @@ function checkPrerequisites(cwd) {
7224
7233
  }
7225
7234
  return { success: true, ghUser };
7226
7235
  }
7236
+ function wasModifiedByTargetRepo(cwd, relPath, allhandsRoot) {
7237
+ const localFile = join9(cwd, relPath);
7238
+ const localBlobHash = getFileBlobHash(localFile, allhandsRoot);
7239
+ if (!localBlobHash) return true;
7240
+ return !fileExistsInHistory(relPath, localBlobHash, allhandsRoot);
7241
+ }
7227
7242
  function collectFilesToPush(cwd, finalIncludes, finalExcludes) {
7228
7243
  const allhandsRoot = getAllhandsRoot();
7229
7244
  const manifest = new Manifest(allhandsRoot);
@@ -7246,7 +7261,9 @@ function collectFilesToPush(cwd, finalIncludes, finalExcludes) {
7246
7261
  const localFile = join9(cwd, relPath);
7247
7262
  const upstreamFile = join9(allhandsRoot, relPath);
7248
7263
  if (existsSync11(localFile) && filesAreDifferent(localFile, upstreamFile)) {
7249
- filesToPush.push({ path: relPath, type: "M" });
7264
+ if (wasModifiedByTargetRepo(cwd, relPath, allhandsRoot)) {
7265
+ filesToPush.push({ path: relPath, type: "M" });
7266
+ }
7250
7267
  }
7251
7268
  }
7252
7269
  for (const pattern of finalIncludes) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "all-hands-cli",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "Agentic harness for model-first software development",
5
5
  "type": "module",
6
6
  "bin": {
@@ -3,7 +3,7 @@ import { tmpdir } from 'os';
3
3
  import { dirname, join } from 'path';
4
4
  import { minimatch } from 'minimatch';
5
5
  import * as readline from 'readline';
6
- import { git, isGitRepo, getGitFiles } from '../lib/git.js';
6
+ import { git, isGitRepo, getGitFiles, getFileBlobHash, fileExistsInHistory } from '../lib/git.js';
7
7
  import { checkGhAuth, checkGhInstalled, getGhUser, gh } from '../lib/gh.js';
8
8
  import { Manifest, filesAreDifferent } from '../lib/manifest.js';
9
9
  import { getAllhandsRoot, UPSTREAM_REPO } from '../lib/paths.js';
@@ -94,6 +94,23 @@ function checkPrerequisites(cwd: string): PrerequisiteResult {
94
94
  return { success: true, ghUser };
95
95
  }
96
96
 
97
+ /**
98
+ * Determine if a file was actually modified by the target repo,
99
+ * vs simply being out of date because upstream moved forward.
100
+ *
101
+ * Compares the target file's content against all historical versions
102
+ * in the upstream repo. If it matches any previous version, the target
103
+ * repo hasn't modified it.
104
+ */
105
+ function wasModifiedByTargetRepo(cwd: string, relPath: string, allhandsRoot: string): boolean {
106
+ const localFile = join(cwd, relPath);
107
+ const localBlobHash = getFileBlobHash(localFile, allhandsRoot);
108
+
109
+ if (!localBlobHash) return true; // safe default: assume modified on error
110
+
111
+ return !fileExistsInHistory(relPath, localBlobHash, allhandsRoot);
112
+ }
113
+
97
114
  function collectFilesToPush(
98
115
  cwd: string,
99
116
  finalIncludes: string[],
@@ -126,7 +143,9 @@ function collectFilesToPush(
126
143
  const upstreamFile = join(allhandsRoot, relPath);
127
144
 
128
145
  if (existsSync(localFile) && filesAreDifferent(localFile, upstreamFile)) {
129
- filesToPush.push({ path: relPath, type: 'M' });
146
+ if (wasModifiedByTargetRepo(cwd, relPath, allhandsRoot)) {
147
+ filesToPush.push({ path: relPath, type: 'M' });
148
+ }
130
149
  }
131
150
  }
132
151
 
package/src/lib/git.ts CHANGED
@@ -61,3 +61,22 @@ export function getGitFiles(repoPath: string): string[] {
61
61
  }
62
62
  return files;
63
63
  }
64
+
65
+ /**
66
+ * Compute the git blob hash of a file (works on files outside the repo).
67
+ */
68
+ export function getFileBlobHash(filePath: string, repoPath: string): string | null {
69
+ const result = git(['hash-object', filePath], repoPath);
70
+ return result.success ? result.stdout.trim() : null;
71
+ }
72
+
73
+ /**
74
+ * Check if a specific blob hash appears in the git history of a file path.
75
+ * Uses a single `rev-list --objects` call instead of per-commit lookups.
76
+ */
77
+ export function fileExistsInHistory(relPath: string, blobHash: string, repoPath: string): boolean {
78
+ const result = git(['rev-list', 'HEAD', '--objects', '--', relPath], repoPath);
79
+ if (!result.success || !result.stdout) return false;
80
+
81
+ return result.stdout.split('\n').some(line => line.startsWith(blobHash + ' '));
82
+ }