@phnx-labs/agents-cli 1.14.2 → 1.14.3

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.
Files changed (101) hide show
  1. package/README.md +17 -7
  2. package/dist/commands/browser.d.ts +2 -0
  3. package/dist/commands/browser.js +388 -0
  4. package/dist/commands/daemon.js +1 -1
  5. package/dist/commands/doctor.d.ts +16 -9
  6. package/dist/commands/doctor.js +248 -12
  7. package/dist/commands/prune.js +9 -3
  8. package/dist/commands/refresh-rules.d.ts +15 -0
  9. package/dist/commands/{refresh-memory.js → refresh-rules.js} +14 -14
  10. package/dist/commands/routines.js +1 -1
  11. package/dist/commands/rules.js +100 -4
  12. package/dist/commands/secrets.js +198 -11
  13. package/dist/commands/sync.js +19 -0
  14. package/dist/commands/teams.js +162 -22
  15. package/dist/commands/trash.d.ts +10 -0
  16. package/dist/commands/trash.js +187 -0
  17. package/dist/commands/view.js +46 -13
  18. package/dist/index.js +62 -4
  19. package/dist/lib/agents.js +2 -2
  20. package/dist/lib/browser/cdp.d.ts +24 -0
  21. package/dist/lib/browser/cdp.js +94 -0
  22. package/dist/lib/browser/chrome.d.ts +16 -0
  23. package/dist/lib/browser/chrome.js +157 -0
  24. package/dist/lib/browser/drivers/local.d.ts +8 -0
  25. package/dist/lib/browser/drivers/local.js +22 -0
  26. package/dist/lib/browser/drivers/ssh.d.ts +9 -0
  27. package/dist/lib/browser/drivers/ssh.js +129 -0
  28. package/dist/lib/browser/index.d.ts +5 -0
  29. package/dist/lib/browser/index.js +5 -0
  30. package/dist/lib/browser/input.d.ts +6 -0
  31. package/dist/lib/browser/input.js +52 -0
  32. package/dist/lib/browser/ipc.d.ts +12 -0
  33. package/dist/lib/browser/ipc.js +223 -0
  34. package/dist/lib/browser/profiles.d.ts +11 -0
  35. package/dist/lib/browser/profiles.js +61 -0
  36. package/dist/lib/browser/refs.d.ts +21 -0
  37. package/dist/lib/browser/refs.js +88 -0
  38. package/dist/lib/browser/service.d.ts +45 -0
  39. package/dist/lib/browser/service.js +404 -0
  40. package/dist/lib/browser/types.d.ts +73 -0
  41. package/dist/lib/browser/types.js +7 -0
  42. package/dist/lib/cloud/codex.js +1 -1
  43. package/dist/lib/cloud/registry.js +2 -2
  44. package/dist/lib/cloud/rush.js +2 -2
  45. package/dist/lib/cloud/store.js +2 -2
  46. package/dist/lib/daemon.d.ts +1 -1
  47. package/dist/lib/daemon.js +47 -11
  48. package/dist/lib/diff-text.d.ts +25 -0
  49. package/dist/lib/diff-text.js +47 -0
  50. package/dist/lib/doctor-diff.d.ts +64 -0
  51. package/dist/lib/doctor-diff.js +497 -0
  52. package/dist/lib/git.js +3 -3
  53. package/dist/lib/hooks.d.ts +6 -0
  54. package/dist/lib/hooks.js +6 -1
  55. package/dist/lib/migrate.js +77 -0
  56. package/dist/lib/pty-client.js +3 -3
  57. package/dist/lib/pty-server.js +36 -7
  58. package/dist/lib/resources.js +1 -1
  59. package/dist/lib/rotate.d.ts +8 -1
  60. package/dist/lib/rotate.js +17 -4
  61. package/dist/lib/rules/compile.d.ts +104 -0
  62. package/dist/lib/{memory-compile.js → rules/compile.js} +160 -21
  63. package/dist/lib/rules/compose.d.ts +78 -0
  64. package/dist/lib/rules/compose.js +170 -0
  65. package/dist/lib/{memory.d.ts → rules/rules.d.ts} +5 -5
  66. package/dist/lib/{memory.js → rules/rules.js} +10 -10
  67. package/dist/lib/secrets/AgentsKeychain.app/Contents/CodeResources +0 -0
  68. package/dist/lib/secrets/AgentsKeychain.app/Contents/MacOS/AgentsKeychain +0 -0
  69. package/dist/lib/secrets/bundles.d.ts +61 -4
  70. package/dist/lib/secrets/bundles.js +222 -54
  71. package/dist/lib/secrets/index.d.ts +24 -5
  72. package/dist/lib/secrets/index.js +70 -41
  73. package/dist/lib/session/active.js +5 -5
  74. package/dist/lib/session/db.js +4 -4
  75. package/dist/lib/session/discover.js +2 -2
  76. package/dist/lib/session/render.js +21 -7
  77. package/dist/lib/shims.d.ts +28 -4
  78. package/dist/lib/shims.js +72 -14
  79. package/dist/lib/state.d.ts +22 -28
  80. package/dist/lib/state.js +83 -76
  81. package/dist/lib/sync-manifest.d.ts +2 -2
  82. package/dist/lib/sync-manifest.js +5 -5
  83. package/dist/lib/teams/agents.d.ts +4 -2
  84. package/dist/lib/teams/agents.js +11 -4
  85. package/dist/lib/teams/api.d.ts +1 -1
  86. package/dist/lib/teams/api.js +2 -2
  87. package/dist/lib/teams/index.d.ts +1 -0
  88. package/dist/lib/teams/index.js +1 -0
  89. package/dist/lib/teams/persistence.js +3 -3
  90. package/dist/lib/teams/registry.d.ts +8 -1
  91. package/dist/lib/teams/registry.js +8 -2
  92. package/dist/lib/teams/worktree.d.ts +30 -0
  93. package/dist/lib/teams/worktree.js +96 -0
  94. package/dist/lib/types.d.ts +12 -6
  95. package/dist/lib/types.js +3 -3
  96. package/dist/lib/versions.d.ts +30 -2
  97. package/dist/lib/versions.js +127 -105
  98. package/package.json +1 -1
  99. package/scripts/postinstall.js +29 -0
  100. package/dist/commands/refresh-memory.d.ts +0 -15
  101. package/dist/lib/memory-compile.d.ts +0 -66
@@ -76,10 +76,87 @@ function migratePromptcutsIntoHooks() {
76
76
  catch { /* best-effort */ }
77
77
  }
78
78
  }
79
+ /**
80
+ * Move installed agent versions from the legacy single-root layout
81
+ * (~/.agents/versions/<agent>/<ver>/) into the system root
82
+ * (~/.agents-system/versions/<agent>/<ver>/).
83
+ *
84
+ * Pre-split installs put binaries and home dirs under ~/.agents/. After the
85
+ * split, the system code only scans ~/.agents-system/versions/, so without
86
+ * this migration the versions become invisible to listInstalledVersions and
87
+ * every command that depends on it (view, prune, run).
88
+ *
89
+ * Idempotent and non-destructive: if a same-named dest already exists we
90
+ * leave the legacy copy in place so the user can reconcile manually.
91
+ */
92
+ function migrateUserVersionsToSystem() {
93
+ const userVersions = path.join(USER_DIR, 'versions');
94
+ const sysVersions = path.join(SYSTEM_DIR, 'versions');
95
+ if (!fs.existsSync(userVersions))
96
+ return;
97
+ let movedCount = 0;
98
+ let skippedCount = 0;
99
+ let agentEntries;
100
+ try {
101
+ agentEntries = fs.readdirSync(userVersions, { withFileTypes: true });
102
+ }
103
+ catch {
104
+ return;
105
+ }
106
+ for (const agent of agentEntries) {
107
+ if (!agent.isDirectory())
108
+ continue;
109
+ const srcAgentDir = path.join(userVersions, agent.name);
110
+ const dstAgentDir = path.join(sysVersions, agent.name);
111
+ try {
112
+ fs.mkdirSync(dstAgentDir, { recursive: true, mode: 0o700 });
113
+ }
114
+ catch { /* best-effort */ }
115
+ let verEntries;
116
+ try {
117
+ verEntries = fs.readdirSync(srcAgentDir, { withFileTypes: true });
118
+ }
119
+ catch {
120
+ continue;
121
+ }
122
+ for (const ver of verEntries) {
123
+ if (!ver.isDirectory())
124
+ continue;
125
+ const src = path.join(srcAgentDir, ver.name);
126
+ const dst = path.join(dstAgentDir, ver.name);
127
+ if (fs.existsSync(dst)) {
128
+ skippedCount++;
129
+ continue;
130
+ }
131
+ try {
132
+ fs.renameSync(src, dst);
133
+ movedCount++;
134
+ }
135
+ catch { /* best-effort, leave legacy in place */ }
136
+ }
137
+ try {
138
+ if (fs.readdirSync(srcAgentDir).length === 0)
139
+ fs.rmdirSync(srcAgentDir);
140
+ }
141
+ catch { /* best-effort */ }
142
+ }
143
+ try {
144
+ if (fs.readdirSync(userVersions).length === 0)
145
+ fs.rmdirSync(userVersions);
146
+ }
147
+ catch { /* best-effort */ }
148
+ if (movedCount > 0) {
149
+ console.log(`Migrated ${movedCount} version dir${movedCount === 1 ? '' : 's'} from ~/.agents/versions/ to ~/.agents-system/versions/`);
150
+ }
151
+ if (skippedCount > 0) {
152
+ console.log(`Skipped ${skippedCount} version dir${skippedCount === 1 ? '' : 's'} already present in ~/.agents-system/versions/ (kept legacy copy at ~/.agents/versions/)`);
153
+ }
154
+ }
79
155
  /** Run all idempotent migrations. Safe to call multiple times. */
80
156
  export function runMigration() {
81
157
  migrateAgentsYaml();
82
158
  deleteSystemPromptsJson();
83
159
  migrateSystemConfigJson();
84
160
  migratePromptcutsIntoHooks();
161
+ migrateUserVersionsToSystem();
85
162
  }
@@ -9,7 +9,7 @@ import * as fs from 'fs';
9
9
  import { spawn, execSync } from 'child_process';
10
10
  import { fileURLToPath } from 'url';
11
11
  import * as path from 'path';
12
- import { getSocketPath, isPtyServerRunning } from './pty-server.js';
12
+ import { getSocketPath, getPtyLogPath, isPtyServerRunning } from './pty-server.js';
13
13
  const CONNECT_TIMEOUT_MS = 5000;
14
14
  const RESPONSE_TIMEOUT_MS = 30000;
15
15
  /**
@@ -33,7 +33,7 @@ async function ensureServer() {
33
33
  return;
34
34
  // Find the entry point to spawn the server
35
35
  const { bin, args } = getServerSpawnArgs();
36
- const logPath = path.join(path.dirname(getSocketPath()), 'pty.log');
36
+ const logPath = getPtyLogPath();
37
37
  const logFd = fs.openSync(logPath, 'a');
38
38
  const child = spawn(bin, args, {
39
39
  stdio: ['ignore', logFd, logFd],
@@ -57,7 +57,7 @@ async function ensureServer() {
57
57
  }
58
58
  await new Promise(r => setTimeout(r, 100));
59
59
  }
60
- throw new Error('PTY server failed to start within 5 seconds. Check ~/.agents/pty.log');
60
+ throw new Error('PTY server failed to start within 5 seconds. Check ~/.agents-system/helpers/pty/logs.jsonl');
61
61
  }
62
62
  function getServerSpawnArgs() {
63
63
  // Prefer the dist/index.js from the same installation as this code.
@@ -53,9 +53,12 @@ export function captureProcessStartTime(pid) {
53
53
  }
54
54
  // --- Constants ---
55
55
  const SENTINEL = '__AGENTS_PTY_DONE__';
56
+ const PTY_DIR = 'helpers/pty';
56
57
  const SOCKET_NAME = 'pty.sock';
57
58
  const PID_FILE = 'pty.pid';
58
- const LOG_FILE = 'pty.log';
59
+ const LOG_FILE = 'logs.jsonl';
60
+ const LOG_MAX_SIZE = 5 * 1024 * 1024; // 5 MB
61
+ const LOG_ROTATE_COUNT = 3;
59
62
  const SESSION_IDLE_MS = 30 * 60 * 1000; // 30 min
60
63
  const SERVER_IDLE_MS = 60 * 60 * 1000; // 1 hour
61
64
  // --- Path helpers ---
@@ -79,17 +82,24 @@ function buildPtyEnv() {
79
82
  }
80
83
  return env;
81
84
  }
85
+ /** Get the PTY helper directory, creating it if needed. */
86
+ function getPtyDir() {
87
+ const dir = path.join(getAgentsDir(), PTY_DIR);
88
+ fs.mkdirSync(dir, { recursive: true });
89
+ return dir;
90
+ }
82
91
  /** Get the unix socket path for the PTY server. */
83
92
  export function getSocketPath() {
84
- return path.join(getAgentsDir(), SOCKET_NAME);
93
+ return path.join(getPtyDir(), SOCKET_NAME);
85
94
  }
86
95
  /** Get the path to the PTY server PID file. */
87
96
  export function getPtyPidPath() {
88
- return path.join(getAgentsDir(), PID_FILE);
97
+ return path.join(getPtyDir(), PID_FILE);
89
98
  }
90
99
  /** Get the path to the PTY server log file. */
91
100
  export function getPtyLogPath() {
92
- return path.join(getAgentsDir(), LOG_FILE);
101
+ const logDir = getPtyDir();
102
+ return path.join(logDir, LOG_FILE);
93
103
  }
94
104
  /** Check if the PTY server process is alive by probing the stored PID. */
95
105
  export function isPtyServerRunning() {
@@ -112,11 +122,30 @@ export function isPtyServerRunning() {
112
122
  }
113
123
  }
114
124
  // --- Logging ---
125
+ function rotateLogsIfNeeded(logPath) {
126
+ try {
127
+ const stat = fs.statSync(logPath);
128
+ if (stat.size < LOG_MAX_SIZE)
129
+ return;
130
+ for (let i = LOG_ROTATE_COUNT - 1; i >= 1; i--) {
131
+ const older = `${logPath}.${i}`;
132
+ const newer = i === 1 ? logPath : `${logPath}.${i - 1}`;
133
+ if (fs.existsSync(newer)) {
134
+ fs.renameSync(newer, older);
135
+ }
136
+ }
137
+ if (fs.existsSync(logPath)) {
138
+ fs.renameSync(logPath, `${logPath}.1`);
139
+ }
140
+ }
141
+ catch { }
142
+ }
115
143
  function log(level, message) {
116
- const ts = new Date().toISOString();
117
- const line = `[${ts}] [${level}] ${message}\n`;
144
+ const logPath = getPtyLogPath();
145
+ rotateLogsIfNeeded(logPath);
146
+ const entry = { ts: new Date().toISOString(), level, message };
118
147
  try {
119
- fs.appendFileSync(getPtyLogPath(), line, 'utf-8');
148
+ fs.appendFileSync(logPath, JSON.stringify(entry) + '\n', 'utf-8');
120
149
  }
121
150
  catch { }
122
151
  }
@@ -8,7 +8,7 @@ import { AGENTS, listInstalledMcpsWithScope } from './agents.js';
8
8
  import { listInstalledCommandsWithScope } from './commands.js';
9
9
  import { listInstalledSkillsWithScope } from './skills.js';
10
10
  import { listInstalledHooksWithScope } from './hooks.js';
11
- import { listInstalledInstructionsWithScope } from './memory.js';
11
+ import { listInstalledInstructionsWithScope } from './rules/rules.js';
12
12
  import { getEffectiveHome } from './versions.js';
13
13
  import { listMcpServerConfigs } from './mcp.js';
14
14
  import { getProjectAgentsDir, getUserAgentsDir, getSystemAgentsDir, getEnabledExtraRepos, } from './state.js';
@@ -35,7 +35,14 @@ export declare const RUN_STRATEGIES: RunStrategy[];
35
35
  export declare function normalizeRunStrategy(value: unknown): RunStrategy | null;
36
36
  /** Read project-local run strategy from the nearest agents.yaml, if present. */
37
37
  export declare function getProjectRunStrategy(agent: AgentId, startPath: string): RunStrategy | null;
38
- /** Resolve the configured strategy: project agents.yaml, then ~/.agents-system/agents.yaml, then pinned. */
38
+ /**
39
+ * Resolve the configured strategy. Lookup order:
40
+ * 1. project-local agents.yaml (nearest to `startPath`)
41
+ * 2. ~/.agents-system/agents.yaml
42
+ * 3. default: `available` (use the pinned default version when healthy,
43
+ * otherwise fall through to a healthy account so a single rate-limited
44
+ * account doesn't block the run).
45
+ */
39
46
  export declare function getConfiguredRunStrategy(agent: AgentId, startPath?: string): RunStrategy;
40
47
  /** Persist the global run strategy used by bare `agents run <agent>`. */
41
48
  export declare function setGlobalRunStrategy(agent: AgentId, strategy: RunStrategy): void;
@@ -11,6 +11,12 @@ import { getAccountInfo } from './agents.js';
11
11
  import { readMeta, writeMeta, getAgentsDir } from './state.js';
12
12
  import { listInstalledVersions, getVersionHomePath, resolveVersion } from './versions.js';
13
13
  import { getUsageInfoByIdentity, getUsageLookupKey, isClaudeAuthValid, } from './usage.js';
14
+ const ROTATE_DIR = 'helpers/rotate';
15
+ function getRotateDir() {
16
+ const dir = path.join(getAgentsDir(), ROTATE_DIR);
17
+ fs.mkdirSync(dir, { recursive: true });
18
+ return dir;
19
+ }
14
20
  export const RUN_STRATEGIES = ['pinned', 'available', 'balanced'];
15
21
  /**
16
22
  * Return a run strategy when the input is valid, otherwise null.
@@ -47,11 +53,18 @@ export function getProjectRunStrategy(agent, startPath) {
47
53
  }
48
54
  return null;
49
55
  }
50
- /** Resolve the configured strategy: project agents.yaml, then ~/.agents-system/agents.yaml, then pinned. */
56
+ /**
57
+ * Resolve the configured strategy. Lookup order:
58
+ * 1. project-local agents.yaml (nearest to `startPath`)
59
+ * 2. ~/.agents-system/agents.yaml
60
+ * 3. default: `available` (use the pinned default version when healthy,
61
+ * otherwise fall through to a healthy account so a single rate-limited
62
+ * account doesn't block the run).
63
+ */
51
64
  export function getConfiguredRunStrategy(agent, startPath = process.cwd()) {
52
65
  return getProjectRunStrategy(agent, startPath)
53
66
  ?? normalizeRunStrategy(readMeta().run?.[agent]?.strategy)
54
- ?? 'pinned';
67
+ ?? 'available';
55
68
  }
56
69
  /** Persist the global run strategy used by bare `agents run <agent>`. */
57
70
  export function setGlobalRunStrategy(agent, strategy) {
@@ -279,7 +292,7 @@ export async function selectAvailableVersion(agent, preferredVersion) {
279
292
  * a torn write just means the next reader sees a stale timestamp (harmless).
280
293
  */
281
294
  function recordRotationPick(agent, version) {
282
- const stampPath = path.join(getAgentsDir(), `rotate-stamp-${agent}.json`);
295
+ const stampPath = path.join(getRotateDir(), `stamp-${agent}.json`);
283
296
  try {
284
297
  fs.writeFileSync(stampPath, JSON.stringify({ version, ts: Date.now() }), 'utf-8');
285
298
  }
@@ -290,7 +303,7 @@ function recordRotationPick(agent, version) {
290
303
  * or stamp is older than 60 seconds (stale).
291
304
  */
292
305
  function readRotationStamp(agent) {
293
- const stampPath = path.join(getAgentsDir(), `rotate-stamp-${agent}.json`);
306
+ const stampPath = path.join(getRotateDir(), `stamp-${agent}.json`);
294
307
  try {
295
308
  const raw = JSON.parse(fs.readFileSync(stampPath, 'utf-8'));
296
309
  if (Date.now() - raw.ts < 60_000)
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Rules file compilation -- resolving @-imports into a single flat file.
3
+ *
4
+ * Agents that do not natively resolve `@path/to/file` imports (Codex, Cursor)
5
+ * need a pre-compiled rules file with all imports inlined. This module
6
+ * handles that expansion for both user-scope (writes into version home) and
7
+ * project-scope (writes into the workspace).
8
+ */
9
+ import type { AgentId } from '../types.js';
10
+ import { type RulesLayer } from './compose.js';
11
+ /** Sidecar manifest recording source file hashes for staleness detection. */
12
+ export interface CompileManifest {
13
+ compiledAt: string;
14
+ sources: {
15
+ path: string;
16
+ sha256: string;
17
+ mtime?: number;
18
+ size?: number;
19
+ }[];
20
+ }
21
+ /** Result of resolving @-imports in a rules file. */
22
+ export interface ResolveResult {
23
+ /** Fully-inlined content. */
24
+ content: string;
25
+ /** Absolute paths of every file read during resolution (including the root). */
26
+ sources: string[];
27
+ }
28
+ /**
29
+ * Expand all `@path/to/file` imports in `content`, recursively up to
30
+ * MAX_DEPTH. Imports inside fenced code blocks and inline code spans are
31
+ * left alone, matching Claude Code's parser. Missing files are left as-is
32
+ * (silent skip), matching the documented behavior.
33
+ *
34
+ * Relative paths resolve against `baseDir`; absolute and tilde-prefixed
35
+ * paths resolve against the filesystem root / home directory.
36
+ */
37
+ export declare function resolveImports(content: string, baseDir: string): ResolveResult;
38
+ /** True if the agent's native runtime resolves `@path` imports in its rules file. */
39
+ export declare function supportsRulesImports(agentId: AgentId): boolean;
40
+ /**
41
+ * Fast staleness check. Returns true when:
42
+ * - the compiled file or its manifest is missing
43
+ * - any recorded source file is missing
44
+ * - any recorded source's sha256 no longer matches
45
+ *
46
+ * For agents that support @-imports natively, always returns false — there's
47
+ * nothing to compile.
48
+ */
49
+ export declare function isRulesStale(agentId: AgentId, version: string): boolean;
50
+ /**
51
+ * Resolve the source `rules/AGENTS.md` (with all @-imports expanded) and
52
+ * write the result into the version home, alongside a sidecar manifest that
53
+ * records source file hashes for staleness detection.
54
+ *
55
+ * Agents that natively resolve @-imports are skipped (no-op) — their sync
56
+ * uses the standard copyFileSync path in `syncResourcesToVersion`.
57
+ */
58
+ export declare function compileRulesForAgent(agentId: AgentId, version: string): {
59
+ compiled: boolean;
60
+ compiledPath: string;
61
+ sources: number;
62
+ };
63
+ /**
64
+ * Recompile rules if stale. Safe to call on every agent invocation — the
65
+ * staleness check is fast (sha256 of 8-10 small files, ~10-20ms). Returns
66
+ * true if a recompile happened, false otherwise.
67
+ */
68
+ export declare function ensureRulesFresh(agentId: AgentId, version: string): boolean;
69
+ export interface ProjectCompileResult {
70
+ /** True when cwd/AGENTS.md was newly written or rewritten. */
71
+ compiled: boolean;
72
+ /** Absolute path to cwd/AGENTS.md. Empty when no project rules dir was present. */
73
+ agentsPath: string;
74
+ /** Per-agent instruction filenames symlinked (or copied) to AGENTS.md. */
75
+ symlinks: string[];
76
+ /** Number of source files inlined (root + recursive @-imports). */
77
+ sources: number;
78
+ /** Per-agent files we left alone because the user wrote/owns them. */
79
+ skippedClobber: string[];
80
+ }
81
+ /**
82
+ * Compile project-scope rules into a workspace's root memory files so each
83
+ * agent's native loader picks them up.
84
+ *
85
+ * Composes rules from all available layers (project > user > extras > system)
86
+ * with project highest priority — so a project's `subrules/` and `rules.yaml`
87
+ * shadow user/system fragments and presets. Writes `cwd/AGENTS.md` with
88
+ * COMPILED_HEADER_PROJECT and creates symlinks (CLAUDE.md, GEMINI.md,
89
+ * .cursorrules, etc.) → AGENTS.md so every agent finds its expected file at
90
+ * cwd. The agent's own loader merges this project-level file with its
91
+ * user-level rules (in version home) at runtime.
92
+ *
93
+ * Don't-clobber guard: if `cwd/AGENTS.md` exists without our header, the user
94
+ * authored it — leave it alone and report via `skippedClobber`. Same for any
95
+ * pre-existing per-agent file or symlink that doesn't already point at
96
+ * AGENTS.md.
97
+ *
98
+ * No-op when `cwd/.agents/rules/` does not exist. Idempotent on repeated
99
+ * calls — content equality short-circuits the write.
100
+ */
101
+ export declare function compileRulesForProject(cwd: string, opts?: {
102
+ preset?: string;
103
+ layers?: RulesLayer[];
104
+ }): ProjectCompileResult;
@@ -1,16 +1,18 @@
1
1
  /**
2
2
  * Rules file compilation -- resolving @-imports into a single flat file.
3
3
  *
4
- * Agents that do not natively resolve `@path/to/file` imports (Codex, Gemini)
4
+ * Agents that do not natively resolve `@path/to/file` imports (Codex, Cursor)
5
5
  * need a pre-compiled rules file with all imports inlined. This module
6
- * handles that expansion.
6
+ * handles that expansion for both user-scope (writes into version home) and
7
+ * project-scope (writes into the workspace).
7
8
  */
8
9
  import * as fs from 'fs';
9
10
  import * as path from 'path';
10
11
  import * as os from 'os';
11
12
  import * as crypto from 'crypto';
12
- import { AGENTS } from './agents.js';
13
- import { getResolvedRulesDir, getVersionsDir } from './state.js';
13
+ import { AGENTS } from '../agents.js';
14
+ import { getResolvedRulesDir, getVersionsDir } from '../state.js';
15
+ import { composeRules, composeRulesFromState } from './compose.js';
14
16
  // Match `@path` preceded by start-of-string or whitespace. This avoids
15
17
  // matching emails ("foo@bar.com") and the middle of words. The leading
16
18
  // whitespace (if any) is captured so we can preserve it in the output.
@@ -18,6 +20,8 @@ const IMPORT_RE = /(^|\s)@(\S+)/g;
18
20
  const MAX_DEPTH = 5;
19
21
  const COMPILED_HEADER = '<!-- Auto-compiled by agents-cli from ~/.agents/rules/AGENTS.md + imports.\n' +
20
22
  ' Edit the source files under ~/.agents/rules/ — edits to this file will be overwritten on next sync. -->\n\n';
23
+ const COMPILED_HEADER_PROJECT = '<!-- Auto-compiled by agents-cli from .agents/rules/AGENTS.md + imports.\n' +
24
+ ' Edit the source files under .agents/rules/ — edits to this file will be overwritten on next sync. -->\n\n';
21
25
  function expandTilde(p) {
22
26
  if (p === '~')
23
27
  return os.homedir();
@@ -87,10 +91,10 @@ export function resolveImports(content, baseDir) {
87
91
  return { content: result, sources };
88
92
  }
89
93
  /** True if the agent's native runtime resolves `@path` imports in its rules file. */
90
- export function supportsMemoryImports(agentId) {
91
- return !!AGENTS[agentId].capabilities.memoryImports;
94
+ export function supportsRulesImports(agentId) {
95
+ return !!AGENTS[agentId].capabilities.rulesImports;
92
96
  }
93
- function getCompiledMemoryPath(agentId, version) {
97
+ function getCompiledRulesPath(agentId, version) {
94
98
  const agentConfig = AGENTS[agentId];
95
99
  const versionHome = path.join(getVersionsDir(), agentId, version, 'home');
96
100
  return path.join(versionHome, `.${agentId}`, agentConfig.instructionsFile);
@@ -107,10 +111,10 @@ function getManifestPath(compiledPath) {
107
111
  * For agents that support @-imports natively, always returns false — there's
108
112
  * nothing to compile.
109
113
  */
110
- export function isMemoryStale(agentId, version) {
111
- if (supportsMemoryImports(agentId))
114
+ export function isRulesStale(agentId, version) {
115
+ if (supportsRulesImports(agentId))
112
116
  return false;
113
- const compiledPath = getCompiledMemoryPath(agentId, version);
117
+ const compiledPath = getCompiledRulesPath(agentId, version);
114
118
  const manifestPath = getManifestPath(compiledPath);
115
119
  if (!fs.existsSync(compiledPath) || !fs.existsSync(manifestPath))
116
120
  return true;
@@ -143,19 +147,19 @@ export function isMemoryStale(agentId, version) {
143
147
  * Agents that natively resolve @-imports are skipped (no-op) — their sync
144
148
  * uses the standard copyFileSync path in `syncResourcesToVersion`.
145
149
  */
146
- export function compileMemoryForAgent(agentId, version) {
147
- if (supportsMemoryImports(agentId)) {
150
+ export function compileRulesForAgent(agentId, version) {
151
+ if (supportsRulesImports(agentId)) {
148
152
  return { compiled: false, compiledPath: '', sources: 0 };
149
153
  }
150
- const memoryDir = getResolvedRulesDir();
151
- const sourceAgents = path.join(memoryDir, 'AGENTS.md');
154
+ const rulesDir = getResolvedRulesDir();
155
+ const sourceAgents = path.join(rulesDir, 'AGENTS.md');
152
156
  if (!fs.existsSync(sourceAgents)) {
153
157
  return { compiled: false, compiledPath: '', sources: 0 };
154
158
  }
155
159
  const rootContent = fs.readFileSync(sourceAgents, 'utf8');
156
- const { content, sources } = resolveImports(rootContent, memoryDir);
160
+ const { content, sources } = resolveImports(rootContent, rulesDir);
157
161
  const newContent = COMPILED_HEADER + content;
158
- const compiledPath = getCompiledMemoryPath(agentId, version);
162
+ const compiledPath = getCompiledRulesPath(agentId, version);
159
163
  fs.mkdirSync(path.dirname(compiledPath), { recursive: true });
160
164
  const existing = fs.existsSync(compiledPath) ? fs.readFileSync(compiledPath, 'utf8') : null;
161
165
  if (existing === newContent) {
@@ -175,15 +179,150 @@ export function compileMemoryForAgent(agentId, version) {
175
179
  return { compiled: true, compiledPath, sources: allSources.length };
176
180
  }
177
181
  /**
178
- * Recompile memory if stale. Safe to call on every agent invocation — the
182
+ * Recompile rules if stale. Safe to call on every agent invocation — the
179
183
  * staleness check is fast (sha256 of 8-10 small files, ~10-20ms). Returns
180
184
  * true if a recompile happened, false otherwise.
181
185
  */
182
- export function ensureMemoryFresh(agentId, version) {
183
- if (supportsMemoryImports(agentId))
186
+ export function ensureRulesFresh(agentId, version) {
187
+ if (supportsRulesImports(agentId))
184
188
  return false;
185
- if (!isMemoryStale(agentId, version))
189
+ if (!isRulesStale(agentId, version))
186
190
  return false;
187
- const result = compileMemoryForAgent(agentId, version);
191
+ const result = compileRulesForAgent(agentId, version);
188
192
  return result.compiled;
189
193
  }
194
+ /**
195
+ * Compile project-scope rules into a workspace's root memory files so each
196
+ * agent's native loader picks them up.
197
+ *
198
+ * Composes rules from all available layers (project > user > extras > system)
199
+ * with project highest priority — so a project's `subrules/` and `rules.yaml`
200
+ * shadow user/system fragments and presets. Writes `cwd/AGENTS.md` with
201
+ * COMPILED_HEADER_PROJECT and creates symlinks (CLAUDE.md, GEMINI.md,
202
+ * .cursorrules, etc.) → AGENTS.md so every agent finds its expected file at
203
+ * cwd. The agent's own loader merges this project-level file with its
204
+ * user-level rules (in version home) at runtime.
205
+ *
206
+ * Don't-clobber guard: if `cwd/AGENTS.md` exists without our header, the user
207
+ * authored it — leave it alone and report via `skippedClobber`. Same for any
208
+ * pre-existing per-agent file or symlink that doesn't already point at
209
+ * AGENTS.md.
210
+ *
211
+ * No-op when `cwd/.agents/rules/` does not exist. Idempotent on repeated
212
+ * calls — content equality short-circuits the write.
213
+ */
214
+ export function compileRulesForProject(cwd, opts = {}) {
215
+ const projectRulesDir = path.join(cwd, '.agents', 'rules');
216
+ const empty = {
217
+ compiled: false, agentsPath: '', symlinks: [], sources: 0, skippedClobber: [],
218
+ };
219
+ if (!fs.existsSync(projectRulesDir))
220
+ return empty;
221
+ let composed;
222
+ try {
223
+ // Tests inject `layers` to isolate from real ~/.agents-system / ~/.agents
224
+ // state. Production callers omit it and compose from discovered state.
225
+ const result = opts.layers
226
+ ? composeRules({ preset: opts.preset, layers: opts.layers })
227
+ : composeRulesFromState({ cwd, preset: opts.preset });
228
+ composed = { content: result.content, subrules: result.subrules };
229
+ }
230
+ catch {
231
+ // Composer threw (no preset, malformed yaml). Don't write a half-baked
232
+ // file — bail out cleanly, same as if the rules dir didn't exist.
233
+ return empty;
234
+ }
235
+ const newContent = COMPILED_HEADER_PROJECT + composed.content;
236
+ const agentsPath = path.join(cwd, 'AGENTS.md');
237
+ const skippedClobber = [];
238
+ let compiled = false;
239
+ let weOwnAgentsMd = false;
240
+ let agentsLstat = null;
241
+ try {
242
+ agentsLstat = fs.lstatSync(agentsPath);
243
+ }
244
+ catch { /* missing */ }
245
+ if (!agentsLstat) {
246
+ fs.writeFileSync(agentsPath, newContent);
247
+ compiled = true;
248
+ weOwnAgentsMd = true;
249
+ }
250
+ else if (agentsLstat.isFile()) {
251
+ let existing = '';
252
+ try {
253
+ existing = fs.readFileSync(agentsPath, 'utf8');
254
+ }
255
+ catch { /* unreadable */ }
256
+ if (existing.startsWith(COMPILED_HEADER_PROJECT)) {
257
+ if (existing !== newContent) {
258
+ fs.writeFileSync(agentsPath, newContent);
259
+ compiled = true;
260
+ }
261
+ weOwnAgentsMd = true;
262
+ }
263
+ else {
264
+ skippedClobber.push('AGENTS.md');
265
+ }
266
+ }
267
+ else {
268
+ // Symlink or other non-regular file — treat as user-owned, do not clobber
269
+ skippedClobber.push('AGENTS.md');
270
+ }
271
+ // Per-agent symlinks. Only attempt when we own AGENTS.md — never create a
272
+ // dangling symlink to a file we couldn't write.
273
+ const symlinks = [];
274
+ if (weOwnAgentsMd) {
275
+ const seen = new Set(['AGENTS.md']);
276
+ for (const agent of Object.values(AGENTS)) {
277
+ const fname = agent.instructionsFile;
278
+ if (seen.has(fname))
279
+ continue;
280
+ // Skip agents whose instructions live at a nested path (e.g. OpenClaw's
281
+ // workspace/AGENTS.md) — those are managed by their own setup paths.
282
+ if (fname.includes('/') || fname.includes('\\'))
283
+ continue;
284
+ seen.add(fname);
285
+ const linkPath = path.join(cwd, fname);
286
+ let lstat = null;
287
+ try {
288
+ lstat = fs.lstatSync(linkPath);
289
+ }
290
+ catch { /* missing */ }
291
+ if (lstat) {
292
+ if (lstat.isSymbolicLink()) {
293
+ let target = '';
294
+ try {
295
+ target = fs.readlinkSync(linkPath);
296
+ }
297
+ catch { /* unreadable */ }
298
+ if (target === 'AGENTS.md') {
299
+ symlinks.push(fname);
300
+ continue;
301
+ }
302
+ skippedClobber.push(fname);
303
+ continue;
304
+ }
305
+ // Regular file — user authored
306
+ skippedClobber.push(fname);
307
+ continue;
308
+ }
309
+ try {
310
+ fs.symlinkSync('AGENTS.md', linkPath);
311
+ symlinks.push(fname);
312
+ }
313
+ catch {
314
+ // Filesystems that disallow symlinks (some Windows configs) — fall
315
+ // back to a copy. The agent reads the same content either way.
316
+ try {
317
+ fs.copyFileSync(agentsPath, linkPath);
318
+ symlinks.push(fname);
319
+ }
320
+ catch {
321
+ // Give up on this one quietly; the agent that needs this filename
322
+ // will fall back to its own discovery rules.
323
+ }
324
+ }
325
+ }
326
+ }
327
+ return { compiled, agentsPath, symlinks, sources: composed.subrules.length, skippedClobber };
328
+ }