@jmylchreest/aide-plugin 0.0.64 → 0.0.66

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jmylchreest/aide-plugin",
3
- "version": "0.0.64",
3
+ "version": "0.0.66",
4
4
  "description": "aide plugin for OpenCode and Codex CLI — multi-agent orchestration, memory, skills, and persistence",
5
5
  "type": "module",
6
6
  "main": "./src/opencode/index.ts",
@@ -52,7 +52,7 @@
52
52
  },
53
53
  "dependencies": {
54
54
  "cross-spawn": "^7.0.6",
55
- "smol-toml": "^1.3.1",
56
- "which": "^6.0.1"
55
+ "smol-toml": "^1.6.1",
56
+ "which": "^7.0.0"
57
57
  }
58
58
  }
@@ -186,23 +186,39 @@ export function getProjectName(cwd: string): string {
186
186
  }
187
187
 
188
188
  /**
189
- * Load config from .aide/config/aide.json (if it exists).
190
- * Returns DEFAULT_CONFIG if no config file exists or it can't be parsed.
189
+ * Load config from ~/.aide/config/aide.json (global).
190
+ * Used before a project root has been resolved (e.g. by the SessionStart
191
+ * hook deciding whether to honour `requireGit`).
192
+ */
193
+ export function loadGlobalConfig(): AideConfig {
194
+ const configPath = join(homedir(), ".aide", "config", "aide.json");
195
+ if (!existsSync(configPath)) return DEFAULT_CONFIG;
196
+ try {
197
+ return { ...DEFAULT_CONFIG, ...JSON.parse(readFileSync(configPath, "utf-8")) };
198
+ } catch {
199
+ return DEFAULT_CONFIG;
200
+ }
201
+ }
202
+
203
+ /**
204
+ * Load config layered as: defaults → global (~/.aide/config/aide.json) →
205
+ * project (`<cwd>/.aide/config/aide.json`). Project values override global.
191
206
  * Does NOT create a default config file — only user-set values are persisted.
192
207
  */
193
208
  export function loadConfig(cwd: string): AideConfig {
194
- const configPath = join(cwd, ".aide", "config", "aide.json");
209
+ const global = loadGlobalConfig();
210
+ const projectPath = join(cwd, ".aide", "config", "aide.json");
195
211
 
196
- if (existsSync(configPath)) {
212
+ if (existsSync(projectPath)) {
197
213
  try {
198
- const content = readFileSync(configPath, "utf-8");
199
- return { ...DEFAULT_CONFIG, ...JSON.parse(content) };
214
+ const project = JSON.parse(readFileSync(projectPath, "utf-8"));
215
+ return { ...DEFAULT_CONFIG, ...global, ...project };
200
216
  } catch {
201
- return DEFAULT_CONFIG;
217
+ return global;
202
218
  }
203
219
  }
204
220
 
205
- return DEFAULT_CONFIG;
221
+ return global;
206
222
  }
207
223
 
208
224
  /**
package/src/core/types.ts CHANGED
@@ -9,6 +9,16 @@
9
9
  // =============================================================================
10
10
 
11
11
  export interface AideConfig {
12
+ /**
13
+ * When true (default), AIDE refuses to bootstrap if no `.git/` or `.aide/`
14
+ * marker is found walking up from the launched cwd. This prevents the hook
15
+ * from planting an orphan `.aide/` folder in an arbitrary subdirectory of a
16
+ * git repo when `claude` is launched there. Set to false in
17
+ * `~/.aide/config/aide.json` to allow init in non-git directories.
18
+ * Only the global-config value is consulted (the project layer is moot
19
+ * because, if a project root was found, the gate has already passed).
20
+ */
21
+ requireGit?: boolean;
12
22
  share?: {
13
23
  /** Auto-import shared data from .aide/shared/ on session start (default: false) */
14
24
  autoImport?: boolean;
@@ -26,10 +26,12 @@ import { homedir } from "os";
26
26
  import { Logger, debug, setDebugCwd } from "../lib/logger.js";
27
27
  import { readStdin, detectPlatform } from "../lib/hook-utils.js";
28
28
  import { findAideBinary, ensureAideBinary } from "../lib/aide-downloader.js";
29
+ import { findProjectRoot } from "../lib/project-root.js";
29
30
  import { recordTokenEvent } from "../core/read-tracking.js";
30
31
  import {
31
32
  ensureDirectories as coreEnsureDirectories,
32
33
  loadConfig as coreLoadConfig,
34
+ loadGlobalConfig as coreLoadGlobalConfig,
33
35
  initializeSession as coreInitializeSession,
34
36
  cleanupStaleStateFiles as coreCleanupStaleStateFiles,
35
37
  resetHudState as coreResetHudState,
@@ -340,13 +342,35 @@ async function main(): Promise<void> {
340
342
  }
341
343
 
342
344
  const data: HookInput = JSON.parse(input);
343
- const cwd = data.cwd || process.cwd();
345
+ const launchedCwd = data.cwd || process.cwd();
344
346
  const sessionId = data.session_id || "unknown";
345
347
 
348
+ // Resolve the project root so we never plant a sibling .aide/ in a
349
+ // subdirectory of a git repo. Mirrors the Go binary's findProjectRoot().
350
+ const { root: resolvedRoot, hasMarker } = findProjectRoot(launchedCwd);
351
+ if (!hasMarker) {
352
+ const requireGit = coreLoadGlobalConfig().requireGit ?? true;
353
+ if (requireGit) {
354
+ process.stderr.write(
355
+ `[aide] No .git/ or .aide/ found walking up from ${launchedCwd}. ` +
356
+ `Set \`requireGit\`: false in ~/.aide/config/aide.json to allow ` +
357
+ `init in arbitrary directories. Skipping AIDE bootstrap.\n`,
358
+ );
359
+ console.log(JSON.stringify({ continue: true }));
360
+ return;
361
+ }
362
+ process.stderr.write(
363
+ `[aide] No project root found, falling back to ${launchedCwd} (requireGit=false).\n`,
364
+ );
365
+ }
366
+ const cwd = hasMarker ? resolvedRoot : launchedCwd;
367
+
346
368
  // Switch debug logging to project-local logs
347
369
  setDebugCwd(cwd);
348
370
 
349
- debugLog(`Parsed input: cwd=${cwd}, sessionId=${sessionId.slice(0, 8)}`);
371
+ debugLog(
372
+ `Parsed input: cwd=${cwd}, launchedCwd=${launchedCwd}, sessionId=${sessionId.slice(0, 8)}`,
373
+ );
350
374
 
351
375
  // Initialize logger
352
376
  log = new Logger("session-start", cwd);
@@ -28,6 +28,8 @@ import { Readable, Transform } from "stream";
28
28
  import { pipeline } from "stream/promises";
29
29
  // Canonical binary finder — import for local use, re-export for backward compat
30
30
  import { findAideBinary } from "./hook-utils.js";
31
+ import { findProjectRoot } from "./project-root.js";
32
+ import { loadGlobalConfig } from "../core/session-init.js";
31
33
  export { findAideBinary };
32
34
 
33
35
  export interface DownloadResult {
@@ -544,8 +546,24 @@ Downloads the aide binary from GitHub releases.
544
546
  }
545
547
  destDir = join(pluginRoot, "bin");
546
548
  } else if (!destDir) {
547
- // Default to current directory's .aide/bin
548
- destDir = join(process.cwd(), ".aide", "bin");
549
+ // Default: resolve to the project root rather than blindly using cwd.
550
+ // Matches the SessionStart hook so the CLI fallback never plants an
551
+ // orphan .aide/bin/ in a subdirectory of a git repo.
552
+ const { root, hasMarker } = findProjectRoot(process.cwd());
553
+ if (!hasMarker) {
554
+ const requireGit = loadGlobalConfig().requireGit ?? true;
555
+ if (requireGit) {
556
+ console.error(
557
+ `[aide] No .git/ or .aide/ found walking up from ${process.cwd()}. ` +
558
+ `Set \`requireGit\`: false in ~/.aide/config/aide.json or pass --cwd / --dest to install anyway.`,
559
+ );
560
+ process.exit(1);
561
+ }
562
+ console.error(
563
+ `[aide] No project root found, installing into ${process.cwd()} (requireGit=false).`,
564
+ );
565
+ }
566
+ destDir = join(hasMarker ? root : process.cwd(), ".aide", "bin");
549
567
  }
550
568
 
551
569
  const result = await downloadAideBinary(destDir, { force, quiet: false });
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Project root resolution for the AIDE plugin.
3
+ *
4
+ * Mirrors the Go binary's findProjectRoot() in aide/cmd/aide/main.go so that
5
+ * the TypeScript hook layer and the Go binary always agree on where `.aide/`
6
+ * lives. Without this, the hook would plant a sibling `.aide/` in whatever
7
+ * subdirectory `claude` was launched from, while the Go binary would walk up
8
+ * and use the real one at the repo root.
9
+ *
10
+ * Resolution order, matching main.go:findProjectRoot:
11
+ * 1. AIDE_PROJECT_ROOT env override (must be an existing directory).
12
+ * 2. Walk up from cwd. At each level:
13
+ * a. .aide/ — return this dir. Skip ~/.aide/ unless cwd === $HOME.
14
+ * b. .git/ directory — return this dir.
15
+ * c. .git/ file (worktree pointer) — resolve to the main repo root.
16
+ * 3. No marker found: return { root: cwd, hasMarker: false }.
17
+ */
18
+
19
+ import { basename, dirname, join, resolve } from "path";
20
+ import { existsSync, readFileSync, statSync } from "fs";
21
+ import { homedir } from "os";
22
+
23
+ export interface ProjectRootResult {
24
+ root: string;
25
+ hasMarker: boolean;
26
+ }
27
+
28
+ /**
29
+ * Resolve the AIDE project root for a given cwd.
30
+ *
31
+ * `hasMarker` is true when an actual `.aide/` or `.git/` marker was found
32
+ * (or when AIDE_PROJECT_ROOT is set to an existing directory). When false,
33
+ * `root` is just the input cwd — callers should decide whether to fall
34
+ * back to it (e.g. via the `requireGit` config) or refuse to bootstrap.
35
+ */
36
+ export function findProjectRoot(cwd: string): ProjectRootResult {
37
+ const override = process.env.AIDE_PROJECT_ROOT;
38
+ if (override) {
39
+ try {
40
+ const abs = resolve(override);
41
+ const stat = statSync(abs);
42
+ if (stat.isDirectory()) {
43
+ return { root: abs, hasMarker: true };
44
+ }
45
+ } catch {
46
+ // Fall through to the walk-up.
47
+ }
48
+ process.stderr.write(
49
+ `aide: AIDE_PROJECT_ROOT=${JSON.stringify(override)} is not a directory; falling back to walk-up\n`,
50
+ );
51
+ }
52
+
53
+ const startCwd = resolve(cwd);
54
+ const home = homedir();
55
+
56
+ let dir = startCwd;
57
+ for (;;) {
58
+ const aidePath = join(dir, ".aide");
59
+ if (existsSync(aidePath)) {
60
+ // Skip ~/.aide/ unless cwd is $HOME itself. ~/.aide/ is the global
61
+ // config dir, not a project marker.
62
+ if (!(home && dir === home && startCwd !== home)) {
63
+ return { root: dir, hasMarker: true };
64
+ }
65
+ }
66
+
67
+ const gitPath = join(dir, ".git");
68
+ if (existsSync(gitPath)) {
69
+ try {
70
+ const stat = statSync(gitPath);
71
+ if (stat.isDirectory()) {
72
+ return { root: dir, hasMarker: true };
73
+ }
74
+ if (stat.isFile()) {
75
+ const mainRoot = resolveWorktreeGitFile(gitPath);
76
+ if (mainRoot) {
77
+ return { root: mainRoot, hasMarker: true };
78
+ }
79
+ return { root: dir, hasMarker: true };
80
+ }
81
+ } catch {
82
+ return { root: dir, hasMarker: true };
83
+ }
84
+ }
85
+
86
+ const parent = dirname(dir);
87
+ if (parent === dir) {
88
+ return { root: startCwd, hasMarker: false };
89
+ }
90
+ dir = parent;
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Walk up from `startDir` looking for `.aide/` or `.git/` markers.
96
+ * Returns the resolved root directory, or null when nothing is found.
97
+ *
98
+ * Thin wrapper around findProjectRoot for callers that want a nullable
99
+ * result rather than the {root,hasMarker} shape (e.g. the OpenCode plugin
100
+ * which has its own fallback chain).
101
+ */
102
+ export function walkUpForProjectRoot(startDir: string): string | null {
103
+ const { root, hasMarker } = findProjectRoot(startDir);
104
+ return hasMarker ? root : null;
105
+ }
106
+
107
+ /**
108
+ * Read a .git worktree file ("gitdir: <path>") and return the main repo root.
109
+ *
110
+ * Mirrors aide/cmd/aide/main.go:resolveWorktreeRoot(). The file's gitdir
111
+ * normally points at "<main>/.git/worktrees/<name>"; we walk up that path
112
+ * until we find a component named ".git" and return its parent.
113
+ */
114
+ export function resolveWorktreeGitFile(gitFilePath: string): string | null {
115
+ try {
116
+ const content = readFileSync(gitFilePath, "utf-8").trim();
117
+ if (!content.startsWith("gitdir:")) return null;
118
+
119
+ let gitdir = content.slice("gitdir:".length).trim();
120
+ if (!gitdir.startsWith("/")) {
121
+ gitdir = resolve(dirname(gitFilePath), gitdir);
122
+ }
123
+
124
+ let candidate = gitdir;
125
+ for (;;) {
126
+ const parent = dirname(candidate);
127
+ if (parent === candidate) break;
128
+ if (basename(candidate) === ".git") {
129
+ return parent;
130
+ }
131
+ candidate = parent;
132
+ }
133
+ return null;
134
+ } catch {
135
+ return null;
136
+ }
137
+ }
@@ -36,11 +36,11 @@
36
36
  * ```
37
37
  */
38
38
 
39
- import { basename, dirname, join, resolve } from "path";
39
+ import { dirname, join, resolve } from "path";
40
40
  import { fileURLToPath } from "url";
41
- import { existsSync, readFileSync, statSync } from "fs";
42
41
  import { createHooks } from "./hooks.js";
43
42
  import { isDebugEnabled } from "../lib/logger.js";
43
+ import { walkUpForProjectRoot } from "../lib/project-root.js";
44
44
  import type { Plugin, PluginInput, Hooks } from "./types.js";
45
45
 
46
46
  // Resolve the plugin package root so we can find bundled skills.
@@ -121,84 +121,6 @@ function resolveProjectRoot(ctx: PluginInput): {
121
121
  return { root: directory || "/", hasProjectRoot: false };
122
122
  }
123
123
 
124
- /**
125
- * Walk up from `startDir` looking for .aide/ or .git/ directories.
126
- * Returns the project root path, or null if none found.
127
- *
128
- * For git worktrees, .git is a file containing "gitdir: <path>".
129
- * We follow it to the main repo root, matching the Go binary's
130
- * resolveWorktreeRoot() behavior.
131
- */
132
- function walkUpForProjectRoot(startDir: string): string | null {
133
- let dir = resolve(startDir);
134
- for (;;) {
135
- if (existsSync(join(dir, ".aide"))) {
136
- return dir;
137
- }
138
- const gitPath = join(dir, ".git");
139
- if (existsSync(gitPath)) {
140
- try {
141
- const stat = statSync(gitPath);
142
- if (stat.isDirectory()) {
143
- // Normal git repo
144
- return dir;
145
- }
146
- if (stat.isFile()) {
147
- // Worktree: .git is a file containing "gitdir: <path>"
148
- // Follow it to the main repo root.
149
- const mainRoot = resolveWorktreeGitFile(gitPath);
150
- if (mainRoot) return mainRoot;
151
- // Fallback to current dir if resolution fails
152
- return dir;
153
- }
154
- } catch {
155
- return dir;
156
- }
157
- }
158
- const parent = resolve(dir, "..");
159
- if (parent === dir) {
160
- return null;
161
- }
162
- dir = parent;
163
- }
164
- }
165
-
166
- /**
167
- * Read a .git worktree file and resolve to the main repository root.
168
- * Mirrors the Go binary's resolveWorktreeRoot() in main.go.
169
- *
170
- * The file contains "gitdir: /path/to/repo/.git/worktrees/<name>".
171
- * We walk up from that gitdir path to find the .git directory,
172
- * then return its parent.
173
- */
174
- function resolveWorktreeGitFile(gitFilePath: string): string | null {
175
- try {
176
- const content = readFileSync(gitFilePath, "utf-8").trim();
177
- if (!content.startsWith("gitdir:")) return null;
178
-
179
- let gitdir = content.slice("gitdir:".length).trim();
180
- // Make absolute if relative
181
- if (!gitdir.startsWith("/")) {
182
- gitdir = resolve(dirname(gitFilePath), gitdir);
183
- }
184
-
185
- // Walk up from .git/worktrees/<name> to find the .git directory,
186
- // then return its parent as the repo root.
187
- let candidate = gitdir;
188
- for (;;) {
189
- const parent = dirname(candidate);
190
- if (parent === candidate) break;
191
- if (basename(candidate) === ".git") {
192
- return parent;
193
- }
194
- candidate = parent;
195
- }
196
- return null;
197
- } catch {
198
- return null;
199
- }
200
- }
201
-
202
124
  export const AidePlugin: Plugin = async (ctx: PluginInput): Promise<Hooks> => {
203
125
  // Log raw plugin input BEFORE any resolution for diagnostics.
204
126
  // This is the key to understanding what OpenCode actually passes.