@phnx-labs/agents-cli 1.14.2 → 1.14.4

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 (121) hide show
  1. package/README.md +17 -7
  2. package/dist/browser.d.ts +2 -0
  3. package/dist/browser.js +7 -0
  4. package/dist/commands/browser.d.ts +3 -0
  5. package/dist/commands/browser.js +392 -0
  6. package/dist/commands/daemon.js +1 -1
  7. package/dist/commands/doctor.d.ts +16 -9
  8. package/dist/commands/doctor.js +248 -12
  9. package/dist/commands/prune.js +9 -3
  10. package/dist/commands/refresh-rules.d.ts +15 -0
  11. package/dist/commands/{refresh-memory.js → refresh-rules.js} +14 -14
  12. package/dist/commands/routines.js +1 -1
  13. package/dist/commands/rules.js +100 -4
  14. package/dist/commands/secrets.js +198 -11
  15. package/dist/commands/sync.js +19 -0
  16. package/dist/commands/teams.js +184 -22
  17. package/dist/commands/trash.d.ts +10 -0
  18. package/dist/commands/trash.js +187 -0
  19. package/dist/commands/view.js +47 -14
  20. package/dist/index.js +62 -4
  21. package/dist/lib/agents.js +2 -2
  22. package/dist/lib/browser/cdp.d.ts +24 -0
  23. package/dist/lib/browser/cdp.js +94 -0
  24. package/dist/lib/browser/chrome.d.ts +16 -0
  25. package/dist/lib/browser/chrome.js +157 -0
  26. package/dist/lib/browser/drivers/local.d.ts +8 -0
  27. package/dist/lib/browser/drivers/local.js +22 -0
  28. package/dist/lib/browser/drivers/ssh.d.ts +9 -0
  29. package/dist/lib/browser/drivers/ssh.js +129 -0
  30. package/dist/lib/browser/index.d.ts +5 -0
  31. package/dist/lib/browser/index.js +5 -0
  32. package/dist/lib/browser/input.d.ts +6 -0
  33. package/dist/lib/browser/input.js +52 -0
  34. package/dist/lib/browser/ipc.d.ts +12 -0
  35. package/dist/lib/browser/ipc.js +223 -0
  36. package/dist/lib/browser/profiles.d.ts +11 -0
  37. package/dist/lib/browser/profiles.js +61 -0
  38. package/dist/lib/browser/refs.d.ts +21 -0
  39. package/dist/lib/browser/refs.js +88 -0
  40. package/dist/lib/browser/service.d.ts +45 -0
  41. package/dist/lib/browser/service.js +404 -0
  42. package/dist/lib/browser/types.d.ts +73 -0
  43. package/dist/lib/browser/types.js +7 -0
  44. package/dist/lib/cloud/codex.js +1 -1
  45. package/dist/lib/cloud/registry.js +2 -2
  46. package/dist/lib/cloud/rush.js +2 -2
  47. package/dist/lib/cloud/store.js +2 -2
  48. package/dist/lib/daemon.d.ts +1 -1
  49. package/dist/lib/daemon.js +47 -11
  50. package/dist/lib/diff-text.d.ts +25 -0
  51. package/dist/lib/diff-text.js +47 -0
  52. package/dist/lib/doctor-diff.d.ts +64 -0
  53. package/dist/lib/doctor-diff.js +497 -0
  54. package/dist/lib/git.js +3 -3
  55. package/dist/lib/hooks.d.ts +6 -0
  56. package/dist/lib/hooks.js +6 -1
  57. package/dist/lib/migrate.js +123 -0
  58. package/dist/lib/pty-client.js +3 -3
  59. package/dist/lib/pty-server.js +36 -7
  60. package/dist/lib/resources/commands.d.ts +46 -0
  61. package/dist/lib/resources/commands.js +208 -0
  62. package/dist/lib/resources/hooks.d.ts +12 -0
  63. package/dist/lib/resources/hooks.js +136 -0
  64. package/dist/lib/resources/index.d.ts +36 -0
  65. package/dist/lib/resources/index.js +69 -0
  66. package/dist/lib/resources/mcp.d.ts +34 -0
  67. package/dist/lib/resources/mcp.js +483 -0
  68. package/dist/lib/resources/permissions.d.ts +13 -0
  69. package/dist/lib/resources/permissions.js +184 -0
  70. package/dist/lib/resources/rules.d.ts +43 -0
  71. package/dist/lib/resources/rules.js +146 -0
  72. package/dist/lib/resources/skills.d.ts +37 -0
  73. package/dist/lib/resources/skills.js +238 -0
  74. package/dist/lib/resources/subagents.d.ts +46 -0
  75. package/dist/lib/resources/subagents.js +198 -0
  76. package/dist/lib/resources/types.d.ts +82 -0
  77. package/dist/lib/resources/types.js +8 -0
  78. package/dist/lib/resources.js +1 -1
  79. package/dist/lib/rotate.d.ts +8 -1
  80. package/dist/lib/rotate.js +17 -4
  81. package/dist/lib/rules/compile.d.ts +104 -0
  82. package/dist/lib/{memory-compile.js → rules/compile.js} +160 -21
  83. package/dist/lib/rules/compose.d.ts +78 -0
  84. package/dist/lib/rules/compose.js +170 -0
  85. package/dist/lib/{memory.d.ts → rules/rules.d.ts} +5 -5
  86. package/dist/lib/{memory.js → rules/rules.js} +10 -10
  87. package/dist/lib/secrets/AgentsKeychain.app/Contents/CodeResources +0 -0
  88. package/dist/lib/secrets/AgentsKeychain.app/Contents/MacOS/AgentsKeychain +0 -0
  89. package/dist/lib/secrets/bundles.d.ts +61 -4
  90. package/dist/lib/secrets/bundles.js +222 -54
  91. package/dist/lib/secrets/index.d.ts +24 -5
  92. package/dist/lib/secrets/index.js +70 -41
  93. package/dist/lib/session/active.js +5 -5
  94. package/dist/lib/session/db.js +4 -4
  95. package/dist/lib/session/discover.js +2 -2
  96. package/dist/lib/session/render.js +21 -7
  97. package/dist/lib/shims.d.ts +28 -4
  98. package/dist/lib/shims.js +72 -14
  99. package/dist/lib/state.d.ts +22 -28
  100. package/dist/lib/state.js +83 -78
  101. package/dist/lib/sync-manifest.d.ts +2 -2
  102. package/dist/lib/sync-manifest.js +5 -5
  103. package/dist/lib/teams/agents.d.ts +4 -2
  104. package/dist/lib/teams/agents.js +11 -4
  105. package/dist/lib/teams/api.d.ts +1 -1
  106. package/dist/lib/teams/api.js +2 -2
  107. package/dist/lib/teams/index.d.ts +1 -0
  108. package/dist/lib/teams/index.js +1 -0
  109. package/dist/lib/teams/persistence.js +3 -3
  110. package/dist/lib/teams/registry.d.ts +12 -1
  111. package/dist/lib/teams/registry.js +12 -2
  112. package/dist/lib/teams/worktree.d.ts +30 -0
  113. package/dist/lib/teams/worktree.js +96 -0
  114. package/dist/lib/types.d.ts +12 -6
  115. package/dist/lib/types.js +3 -3
  116. package/dist/lib/versions.d.ts +32 -3
  117. package/dist/lib/versions.js +147 -119
  118. package/package.json +3 -2
  119. package/scripts/postinstall.js +29 -0
  120. package/dist/commands/refresh-memory.d.ts +0 -15
  121. package/dist/lib/memory-compile.d.ts +0 -66
@@ -0,0 +1,30 @@
1
+ export declare function isGitRepo(dir: string): Promise<boolean>;
2
+ export declare function getGitRoot(dir: string): Promise<string>;
3
+ /**
4
+ * Check if a worktree directory has uncommitted changes.
5
+ */
6
+ export declare function hasUncommittedChanges(worktreePath: string): Promise<boolean>;
7
+ /**
8
+ * Create a new git worktree for a teammate.
9
+ *
10
+ * @param repoDir - Directory inside the git repository
11
+ * @param worktreeName - Name for the worktree (used in path and branch)
12
+ * @returns The absolute path to the created worktree
13
+ */
14
+ export declare function createWorktree(repoDir: string, worktreeName: string): Promise<string>;
15
+ /**
16
+ * Remove a git worktree and optionally its branch.
17
+ *
18
+ * @param repoDir - Directory inside the main git repository (not the worktree)
19
+ * @param worktreeName - Name of the worktree to remove
20
+ * @param deleteBranch - Whether to delete the associated branch
21
+ */
22
+ export declare function removeWorktree(repoDir: string, worktreeName: string, deleteBranch?: boolean): Promise<void>;
23
+ /**
24
+ * Get the worktree path for a given name.
25
+ */
26
+ export declare function getWorktreePath(gitRoot: string, worktreeName: string): string;
27
+ /**
28
+ * Get the branch name for a worktree.
29
+ */
30
+ export declare function getWorktreeBranch(worktreeName: string): string;
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Git worktree utilities for isolated agent execution.
3
+ *
4
+ * Creates/removes temporary worktrees so each teammate can work on
5
+ * its own branch without interfering with others or the main checkout.
6
+ */
7
+ import { execFile } from 'child_process';
8
+ import { promisify } from 'util';
9
+ import * as fs from 'fs/promises';
10
+ import * as path from 'path';
11
+ const execFileAsync = promisify(execFile);
12
+ export async function isGitRepo(dir) {
13
+ try {
14
+ await execFileAsync('git', ['rev-parse', '--git-dir'], { cwd: dir });
15
+ return true;
16
+ }
17
+ catch {
18
+ return false;
19
+ }
20
+ }
21
+ export async function getGitRoot(dir) {
22
+ const { stdout } = await execFileAsync('git', ['rev-parse', '--show-toplevel'], { cwd: dir });
23
+ return stdout.trim();
24
+ }
25
+ /**
26
+ * Check if a worktree directory has uncommitted changes.
27
+ */
28
+ export async function hasUncommittedChanges(worktreePath) {
29
+ try {
30
+ const { stdout } = await execFileAsync('git', ['status', '--porcelain'], { cwd: worktreePath });
31
+ return stdout.trim().length > 0;
32
+ }
33
+ catch {
34
+ return false;
35
+ }
36
+ }
37
+ /**
38
+ * Create a new git worktree for a teammate.
39
+ *
40
+ * @param repoDir - Directory inside the git repository
41
+ * @param worktreeName - Name for the worktree (used in path and branch)
42
+ * @returns The absolute path to the created worktree
43
+ */
44
+ export async function createWorktree(repoDir, worktreeName) {
45
+ const gitRoot = await getGitRoot(repoDir);
46
+ const worktreePath = path.join(gitRoot, '.agents', 'worktrees', worktreeName);
47
+ const branchName = `agents/${worktreeName}`;
48
+ await fs.mkdir(path.dirname(worktreePath), { recursive: true });
49
+ await execFileAsync('git', ['worktree', 'add', '-b', branchName, worktreePath, 'HEAD'], {
50
+ cwd: gitRoot,
51
+ });
52
+ return worktreePath;
53
+ }
54
+ /**
55
+ * Remove a git worktree and optionally its branch.
56
+ *
57
+ * @param repoDir - Directory inside the main git repository (not the worktree)
58
+ * @param worktreeName - Name of the worktree to remove
59
+ * @param deleteBranch - Whether to delete the associated branch
60
+ */
61
+ export async function removeWorktree(repoDir, worktreeName, deleteBranch = true) {
62
+ const gitRoot = await getGitRoot(repoDir);
63
+ const worktreePath = path.join(gitRoot, '.agents', 'worktrees', worktreeName);
64
+ const branchName = `agents/${worktreeName}`;
65
+ try {
66
+ await execFileAsync('git', ['worktree', 'remove', '--force', worktreePath], { cwd: gitRoot });
67
+ }
68
+ catch (err) {
69
+ if (err.message?.includes('is not a working tree')) {
70
+ await execFileAsync('git', ['worktree', 'prune'], { cwd: gitRoot });
71
+ }
72
+ else {
73
+ throw err;
74
+ }
75
+ }
76
+ if (deleteBranch) {
77
+ try {
78
+ await execFileAsync('git', ['branch', '-D', branchName], { cwd: gitRoot });
79
+ }
80
+ catch {
81
+ // Branch might not exist; ignore
82
+ }
83
+ }
84
+ }
85
+ /**
86
+ * Get the worktree path for a given name.
87
+ */
88
+ export function getWorktreePath(gitRoot, worktreeName) {
89
+ return path.join(gitRoot, '.agents', 'worktrees', worktreeName);
90
+ }
91
+ /**
92
+ * Get the branch name for a worktree.
93
+ */
94
+ export function getWorktreeBranch(worktreeName) {
95
+ return `agents/${worktreeName}`;
96
+ }
@@ -41,10 +41,10 @@ export interface AgentConfig {
41
41
  plugins: Capability;
42
42
  /**
43
43
  * Whether the agent natively resolves `@path/to/file` imports inside its
44
- * memory file at session start. If false, agents-cli must pre-compile the
45
- * memory file (inline all @-imports) when syncing it into the version home.
44
+ * rules file at session start. If false, agents-cli must pre-compile the
45
+ * rules file (inline all @-imports) when syncing it into the version home.
46
46
  */
47
- memoryImports?: boolean;
47
+ rulesImports?: boolean;
48
48
  };
49
49
  }
50
50
  /**
@@ -186,9 +186,9 @@ export interface RepoInfo {
186
186
  lastSync: string;
187
187
  }
188
188
  /** Canonical system repo cloned into ~/.agents-system/. */
189
- export declare const DEFAULT_SYSTEM_REPO = "gh:phnx-labs/.agents";
190
- /** Previous system repo (personal fork), kept for migration detection. */
191
- export declare const LEGACY_SYSTEM_REPO = "gh:muqsitnawaz/.agents";
189
+ export declare const DEFAULT_SYSTEM_REPO = "gh:muqsitnawaz/.agents-system";
190
+ /** Mirror system repo (phnx-labs) will become the default once fully released. */
191
+ export declare const MIRROR_SYSTEM_REPO = "gh:phnx-labs/.agents-system";
192
192
  /** Strip the `gh:` prefix and `.git` suffix to get a GitHub `owner/repo` slug. */
193
193
  export declare function systemRepoSlug(repo?: string): string;
194
194
  /** Kind of package that can be searched and installed from a registry. */
@@ -299,6 +299,12 @@ export interface VersionResources {
299
299
  permissions?: string[];
300
300
  subagents?: string[];
301
301
  plugins?: string[];
302
+ /**
303
+ * Active rule preset for this agent@version. The composer reads layered
304
+ * `rules.yaml` files and emits this preset's subrules as the agent's
305
+ * single instruction file. Absent/null means the literal "default" preset.
306
+ */
307
+ rulesPreset?: string;
302
308
  }
303
309
  /** Manifest file (plugin.yaml) at the root of a plugin bundle. */
304
310
  export interface PluginManifest {
package/dist/lib/types.js CHANGED
@@ -6,9 +6,9 @@
6
6
  * formats for each supported agent.
7
7
  */
8
8
  /** Canonical system repo cloned into ~/.agents-system/. */
9
- export const DEFAULT_SYSTEM_REPO = 'gh:phnx-labs/.agents';
10
- /** Previous system repo (personal fork), kept for migration detection. */
11
- export const LEGACY_SYSTEM_REPO = 'gh:muqsitnawaz/.agents';
9
+ export const DEFAULT_SYSTEM_REPO = 'gh:muqsitnawaz/.agents-system';
10
+ /** Mirror system repo (phnx-labs) will become the default once fully released. */
11
+ export const MIRROR_SYSTEM_REPO = 'gh:phnx-labs/.agents-system';
12
12
  /** Strip the `gh:` prefix and `.git` suffix to get a GitHub `owner/repo` slug. */
13
13
  export function systemRepoSlug(repo = DEFAULT_SYSTEM_REPO) {
14
14
  return repo.replace(/^gh:/, '').replace(/\.git$/, '');
@@ -54,8 +54,9 @@ export declare function getActuallySyncedResources(agent: AgentId, version: stri
54
54
  export declare function getNewResources(available: AvailableResources, actuallySynced: AvailableResources): AvailableResources;
55
55
  /**
56
56
  * Check if there are any new resources to sync.
57
+ * When version is provided, uses version-specific capability checks.
57
58
  */
58
- export declare function hasNewResources(diff: AvailableResources, agent?: AgentId): boolean;
59
+ export declare function hasNewResources(diff: AvailableResources, agent?: AgentId, version?: string): boolean;
59
60
  /**
60
61
  * Prompt user to select which NEW resources to sync.
61
62
  * Only shows resources that haven't been synced yet.
@@ -111,6 +112,18 @@ export declare function isLatestInstalled(agent: AgentId): Promise<{
111
112
  * List all installed versions for an agent.
112
113
  */
113
114
  export declare function listInstalledVersions(agent: AgentId): string[];
115
+ /**
116
+ * List every version directory for an agent, including ones missing the
117
+ * binary (typically home-only leftovers from a prior `removeVersion`).
118
+ *
119
+ * Used by `agents prune` to surface stale installs that the regular
120
+ * `listInstalledVersions` filters out. Do NOT use elsewhere — every other
121
+ * call site assumes a working binary.
122
+ */
123
+ export declare function listInstalledVersionDirs(agent: AgentId): Array<{
124
+ version: string;
125
+ hasBinary: boolean;
126
+ }>;
114
127
  /**
115
128
  * Get the global default version for an agent.
116
129
  */
@@ -128,8 +141,24 @@ export declare function installVersion(agent: AgentId, version: string, onProgre
128
141
  error?: string;
129
142
  }>;
130
143
  /**
131
- * Remove a specific version of an agent. Preserves `home/` under the version
132
- * directory so conversation history survives reinstalls.
144
+ * Soft-delete a version directory by moving it to ~/.agents-system/trash/versions/.
145
+ * Returns the trash path on success or null on failure / no source.
146
+ *
147
+ * Trash layout: ~/.agents-system/trash/versions/<agent>/<version>/<timestamp>/
148
+ * The timestamp suffix lets a user soft-delete the same version twice (after
149
+ * re-install) without collision and gives a chronological audit trail.
150
+ *
151
+ * The whole versionDir moves — including `home/` (transcripts, sessions). The
152
+ * user can recover everything via `agents trash restore <agent>@<version>`.
153
+ * Nothing is ever hard-deleted.
154
+ */
155
+ export declare function softDeleteVersionDir(agent: AgentId, version: string): string | null;
156
+ /**
157
+ * Remove a specific version of an agent.
158
+ *
159
+ * Soft-delete only: moves the entire version directory (including `home/`)
160
+ * to ~/.agents-system/trash/versions/. Recoverable via `agents trash restore`.
161
+ * Nothing is hard-deleted.
133
162
  */
134
163
  export declare function removeVersion(agent: AgentId, version: string): boolean;
135
164
  /**
@@ -23,18 +23,18 @@ import { promisify } from 'util';
23
23
  import chalk from 'chalk';
24
24
  import * as TOML from 'smol-toml';
25
25
  import { checkbox, select } from '@inquirer/prompts';
26
- import { getVersionsDir, ensureAgentsDir, readMeta, writeMeta, getCommandsDir, getSkillsDir, getHooksDir, getResolvedRulesDir, getUserRulesDir, clearVersionResources, recordVersionResources, getProjectAgentsDir, getPromptcutsPath, getEnabledExtraRepos, getAgentsDir, getUserAgentsDir } from './state.js';
26
+ import { getVersionsDir, ensureAgentsDir, readMeta, writeMeta, getCommandsDir, getSkillsDir, getHooksDir, getResolvedRulesDir, getUserRulesDir, clearVersionResources, recordVersionResources, getProjectAgentsDir, getPromptcutsPath, getEnabledExtraRepos, getAgentsDir, getUserAgentsDir, getTrashVersionsDir, getActiveRulesPreset } from './state.js';
27
27
  import { resolveResource } from './resources.js';
28
28
  import { AGENTS, getAccountEmail, MCP_CAPABLE_AGENTS, COMMANDS_CAPABLE_AGENTS, getMcpConfigPathForHome, parseMcpConfig, resolveAgentName, formatAgentError } from './agents.js';
29
29
  import { applyPermissionsToVersion as applyPermsToVersion, PERMISSIONS_CAPABLE_AGENTS, discoverPermissionGroups, buildPermissionsFromGroups, CODEX_RULES_FILENAME, getActivePermissionSetName, readPermissionSetRecipe, PERMISSION_SET_ENV_VAR } from './permissions.js';
30
- import { installMcpServers } from './mcp.js';
30
+ import { installMcpServers, parseMcpServerConfig } from './mcp.js';
31
31
  import { markdownToToml } from './convert.js';
32
32
  import { createVersionedAlias, removeVersionedAlias, getConfigSymlinkVersion, ensureClaudeInsideSymlink } from './shims.js';
33
33
  import { listInstalledSubagents, transformSubagentForClaude, syncSubagentToOpenclaw, SUBAGENT_CAPABLE_AGENTS } from './subagents.js';
34
34
  import { registerHooksToSettings } from './hooks.js';
35
35
  import { supports, explainSkip } from './capabilities.js';
36
36
  import { discoverPlugins, syncPluginToVersion, isPluginSynced, pluginSupportsAgent, cleanOrphanedPluginSkills } from './plugins.js';
37
- import { compileMemoryForAgent } from './memory-compile.js';
37
+ import { composeRulesFromState } from './rules/compose.js';
38
38
  import { loadSyncManifest, saveSyncManifest, buildManifest, isSyncStale } from './sync-manifest.js';
39
39
  import { PLUGINS_CAPABLE_AGENTS } from './agents.js';
40
40
  import { safeJoin } from './paths.js';
@@ -117,39 +117,46 @@ export function getAvailableResources(cwd = process.cwd()) {
117
117
  }
118
118
  }
119
119
  result.hooks = Array.from(hookNames);
120
- // Rules (*.md files, excluding symlinks and README)
121
- // Scan 'rules/' first (canonical), then 'memory/' (legacy name) for backward compat.
122
- const memoryNames = new Set();
123
- for (const { base } of resourceBases) {
124
- for (const subdir of ['rules', 'memory']) {
125
- const memoryDir = path.join(base, subdir);
126
- if (!fs.existsSync(memoryDir))
127
- continue;
128
- const names = fs.readdirSync(memoryDir)
129
- .filter(f => {
130
- if (!f.endsWith('.md') || f === RULES_DOC_FILENAME)
131
- return false;
132
- const stat = fs.lstatSync(path.join(memoryDir, f));
133
- return !stat.isSymbolicLink();
134
- })
135
- .map(f => f.replace(/\.md$/, ''));
136
- for (const name of names) {
137
- memoryNames.add(name);
120
+ // Rules — list available presets across layers (project > user > extras > system).
121
+ // The composer selects exactly one preset per sync; this list drives the
122
+ // resource-count display and `agents rules switch` picker. Routes through
123
+ // the rules-dir getters so test mocks work the same as production paths.
124
+ const presetNames = new Set();
125
+ const rulesDirs = [];
126
+ if (projectAgentsDir)
127
+ rulesDirs.push(path.join(projectAgentsDir, 'rules'));
128
+ rulesDirs.push(getUserRulesDir());
129
+ rulesDirs.push(getResolvedRulesDir());
130
+ for (const extra of getEnabledExtraRepos()) {
131
+ rulesDirs.push(path.join(extra.dir, 'rules'));
132
+ }
133
+ for (const rulesDir of rulesDirs) {
134
+ const rulesYamlPath = path.join(rulesDir, 'rules.yaml');
135
+ if (!fs.existsSync(rulesYamlPath))
136
+ continue;
137
+ try {
138
+ const parsed = yaml.parse(fs.readFileSync(rulesYamlPath, 'utf-8'));
139
+ for (const name of Object.keys(parsed?.presets || {})) {
140
+ presetNames.add(name);
138
141
  }
139
142
  }
143
+ catch {
144
+ // malformed rules.yaml — skip silently; the composer will surface the error.
145
+ }
140
146
  }
141
- result.memory = Array.from(memoryNames);
142
- // MCP servers (*.yaml files)
147
+ result.memory = Array.from(presetNames);
148
+ // MCP servers (*.yaml files) — use the `name:` field inside, not filename
143
149
  const mcpNames = new Set();
144
150
  for (const { base } of resourceBases) {
145
151
  const mcpDir = path.join(base, 'mcp');
146
152
  if (!fs.existsSync(mcpDir))
147
153
  continue;
148
- const names = fs.readdirSync(mcpDir)
149
- .filter(f => f.endsWith('.yaml') || f.endsWith('.yml'))
150
- .map(f => f.replace(/\.(yaml|yml)$/, ''));
151
- for (const name of names) {
152
- mcpNames.add(name);
154
+ const files = fs.readdirSync(mcpDir)
155
+ .filter(f => f.endsWith('.yaml') || f.endsWith('.yml'));
156
+ for (const file of files) {
157
+ const config = parseMcpServerConfig(path.join(mcpDir, file));
158
+ if (config?.name)
159
+ mcpNames.add(config.name);
153
160
  }
154
161
  }
155
162
  result.mcp = Array.from(mcpNames);
@@ -316,47 +323,13 @@ export function getActuallySyncedResources(agent, version, options = {}) {
316
323
  }
317
324
  }
318
325
  }
319
- // Rules - check which instruction files are actually in sync (content matches)
320
- const memoryDir = getResolvedRulesDir();
321
- const projectMemoryDir = projectAgentsDir ? path.join(projectAgentsDir, 'rules') : null;
322
- const userMemoryDir = getUserRulesDir();
323
- const memoryFiles = new Set();
324
- if (fs.existsSync(memoryDir)) {
325
- fs.readdirSync(memoryDir).filter(f => f.endsWith('.md') && f !== RULES_DOC_FILENAME).forEach(f => memoryFiles.add(f));
326
- }
327
- if (projectMemoryDir && fs.existsSync(projectMemoryDir)) {
328
- fs.readdirSync(projectMemoryDir).filter(f => f.endsWith('.md') && f !== RULES_DOC_FILENAME).forEach(f => memoryFiles.add(f));
329
- }
330
- if (fs.existsSync(userMemoryDir)) {
331
- fs.readdirSync(userMemoryDir).filter(f => f.endsWith('.md') && f !== RULES_DOC_FILENAME).forEach(f => memoryFiles.add(f));
332
- }
333
- for (const file of memoryFiles) {
334
- const memName = file.replace(/\.md$/, '');
335
- const targetName = file === 'AGENTS.md' ? agentConfig.instructionsFile : file;
336
- const versionFile = path.join(configDir, targetName);
337
- if (!fs.existsSync(versionFile))
338
- continue;
339
- const projectFile = projectMemoryDir ? path.join(projectMemoryDir, file) : null;
340
- const centralFile = path.join(memoryDir, file);
341
- const userFile = path.join(userMemoryDir, file);
342
- const hasProject = projectFile ? fs.existsSync(projectFile) : false;
343
- const hasUser = fs.existsSync(userFile);
344
- const hasCentral = fs.existsSync(centralFile);
345
- const sourceFile = hasProject ? projectFile : hasUser ? userFile : centralFile;
346
- if (!hasProject && !hasCentral && !hasUser) {
347
- result.memory.push(memName);
348
- continue;
349
- }
350
- try {
351
- const centralContent = fs.readFileSync(sourceFile, 'utf-8');
352
- const versionContent = fs.readFileSync(versionFile, 'utf-8');
353
- if (centralContent === versionContent) {
354
- result.memory.push(memName);
355
- }
356
- }
357
- catch {
358
- // Ignore
359
- }
326
+ // Rules single composed instruction file per agent. If the file exists in
327
+ // the version home, we consider the active preset synced. Available presets
328
+ // are surfaced from rules.yaml; this set is the subset that materialized.
329
+ const instrFile = path.join(configDir, agentConfig.instructionsFile);
330
+ if (fs.existsSync(instrFile)) {
331
+ const activePreset = getActiveRulesPreset(agent, version);
332
+ result.memory.push(activePreset);
360
333
  }
361
334
  // MCP - use canonical config path + parser per agent
362
335
  if (MCP_CAPABLE_AGENTS.includes(agent)) {
@@ -487,7 +460,11 @@ export function getNewResources(available, actuallySynced) {
487
460
  commands: available.commands.filter(c => !actuallySynced.commands.includes(c)),
488
461
  skills: available.skills.filter(s => !actuallySynced.skills.includes(s)),
489
462
  hooks: available.hooks.filter(h => !actuallySynced.hooks.includes(h)),
490
- memory: available.memory.filter(m => !actuallySynced.memory.includes(m)),
463
+ // Memory/rules presets are mutually exclusive — only one can be active.
464
+ // If any preset is synced, don't report others as "new".
465
+ memory: actuallySynced.memory.length > 0
466
+ ? []
467
+ : available.memory.filter(m => !actuallySynced.memory.includes(m)),
491
468
  mcp: available.mcp.filter(m => !actuallySynced.mcp.includes(m)),
492
469
  permissions: available.permissions.filter(p => !actuallySynced.permissions.includes(p)),
493
470
  subagents: available.subagents.filter(s => !actuallySynced.subagents.includes(s)),
@@ -499,14 +476,15 @@ export function getNewResources(available, actuallySynced) {
499
476
  }
500
477
  /**
501
478
  * Check if there are any new resources to sync.
479
+ * When version is provided, uses version-specific capability checks.
502
480
  */
503
- export function hasNewResources(diff, agent) {
504
- const commandsApply = agent ? COMMANDS_CAPABLE_AGENTS.includes(agent) : true;
505
- const hooksApply = agent ? AGENTS[agent].supportsHooks : true;
506
- const mcpApply = agent ? MCP_CAPABLE_AGENTS.includes(agent) : true;
507
- const permsApply = agent ? PERMISSIONS_CAPABLE_AGENTS.includes(agent) : true;
481
+ export function hasNewResources(diff, agent, version) {
482
+ const commandsApply = agent ? supports(agent, 'commands', version).ok : true;
483
+ const hooksApply = agent ? supports(agent, 'hooks', version).ok : true;
484
+ const mcpApply = agent ? supports(agent, 'mcp', version).ok : true;
485
+ const permsApply = agent ? supports(agent, 'allowlist', version).ok : true;
508
486
  const subagentsApply = agent ? SUBAGENT_CAPABLE_AGENTS.includes(agent) : true;
509
- const pluginsApply = agent ? PLUGINS_CAPABLE_AGENTS.includes(agent) : true;
487
+ const pluginsApply = agent ? supports(agent, 'plugins', version).ok : true;
510
488
  return ((diff.commands.length > 0 && commandsApply) ||
511
489
  diff.skills.length > 0 ||
512
490
  (diff.hooks.length > 0 && hooksApply) ||
@@ -886,6 +864,31 @@ export function listInstalledVersions(agent) {
886
864
  }
887
865
  return versions.sort(compareVersions);
888
866
  }
867
+ /**
868
+ * List every version directory for an agent, including ones missing the
869
+ * binary (typically home-only leftovers from a prior `removeVersion`).
870
+ *
871
+ * Used by `agents prune` to surface stale installs that the regular
872
+ * `listInstalledVersions` filters out. Do NOT use elsewhere — every other
873
+ * call site assumes a working binary.
874
+ */
875
+ export function listInstalledVersionDirs(agent) {
876
+ const agentVersionsDir = path.join(getVersionsDir(), agent);
877
+ if (!fs.existsSync(agentVersionsDir)) {
878
+ return [];
879
+ }
880
+ const entries = fs.readdirSync(agentVersionsDir, { withFileTypes: true });
881
+ const out = [];
882
+ for (const entry of entries) {
883
+ if (!entry.isDirectory())
884
+ continue;
885
+ out.push({
886
+ version: entry.name,
887
+ hasBinary: fs.existsSync(getBinaryPath(agent, entry.name)),
888
+ });
889
+ }
890
+ return out.sort((a, b) => compareVersions(a.version, b.version));
891
+ }
889
892
  /**
890
893
  * Get the global default version for an agent.
891
894
  */
@@ -1000,8 +1003,9 @@ export async function installVersion(agent, version, onProgress) {
1000
1003
  /**
1001
1004
  * Remove install artifacts from a version directory, preserving `home/` which
1002
1005
  * contains the user's conversation history, sessions, history.jsonl, tasks,
1003
- * todos, file-history, etc. Called by removeVersion so that uninstalling a
1004
- * version never deletes the user's transcripts.
1006
+ * todos, file-history, etc. Used by the install pipeline (NOT by removeVersion)
1007
+ * to clean up staging artifacts when a fresh install collides with an existing
1008
+ * dir. removeVersion uses soft-delete instead.
1005
1009
  */
1006
1010
  function removeInstallArtifacts(versionDir) {
1007
1011
  for (const entry of fs.readdirSync(versionDir)) {
@@ -1011,15 +1015,50 @@ function removeInstallArtifacts(versionDir) {
1011
1015
  }
1012
1016
  }
1013
1017
  /**
1014
- * Remove a specific version of an agent. Preserves `home/` under the version
1015
- * directory so conversation history survives reinstalls.
1018
+ * Soft-delete a version directory by moving it to ~/.agents-system/trash/versions/.
1019
+ * Returns the trash path on success or null on failure / no source.
1020
+ *
1021
+ * Trash layout: ~/.agents-system/trash/versions/<agent>/<version>/<timestamp>/
1022
+ * The timestamp suffix lets a user soft-delete the same version twice (after
1023
+ * re-install) without collision and gives a chronological audit trail.
1024
+ *
1025
+ * The whole versionDir moves — including `home/` (transcripts, sessions). The
1026
+ * user can recover everything via `agents trash restore <agent>@<version>`.
1027
+ * Nothing is ever hard-deleted.
1028
+ */
1029
+ export function softDeleteVersionDir(agent, version) {
1030
+ const versionDir = getVersionDir(agent, version);
1031
+ if (!fs.existsSync(versionDir))
1032
+ return null;
1033
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
1034
+ const trashRoot = getTrashVersionsDir();
1035
+ const trashAgentDir = path.join(trashRoot, agent, version);
1036
+ const trashDest = path.join(trashAgentDir, stamp);
1037
+ try {
1038
+ fs.mkdirSync(trashAgentDir, { recursive: true, mode: 0o700 });
1039
+ fs.renameSync(versionDir, trashDest);
1040
+ return trashDest;
1041
+ }
1042
+ catch {
1043
+ return null;
1044
+ }
1045
+ }
1046
+ /**
1047
+ * Remove a specific version of an agent.
1048
+ *
1049
+ * Soft-delete only: moves the entire version directory (including `home/`)
1050
+ * to ~/.agents-system/trash/versions/. Recoverable via `agents trash restore`.
1051
+ * Nothing is hard-deleted.
1016
1052
  */
1017
1053
  export function removeVersion(agent, version) {
1018
1054
  const versionDir = getVersionDir(agent, version);
1019
1055
  if (!fs.existsSync(versionDir)) {
1020
1056
  return false;
1021
1057
  }
1022
- removeInstallArtifacts(versionDir);
1058
+ const trashPath = softDeleteVersionDir(agent, version);
1059
+ if (!trashPath) {
1060
+ return false;
1061
+ }
1023
1062
  // Remove versioned alias (e.g., claude@2.0.65)
1024
1063
  removeVersionedAlias(agent, version);
1025
1064
  // Clear resource tracking for this version
@@ -1308,10 +1347,10 @@ export function getResourceDiff(agent, version) {
1308
1347
  }
1309
1348
  }
1310
1349
  // Rules: check individual file symlinks
1311
- const centralMemory = getResolvedRulesDir();
1312
- if (fs.existsSync(centralMemory)) {
1313
- const memoryFiles = fs.readdirSync(centralMemory).filter(f => f.endsWith('.md') && f !== RULES_DOC_FILENAME);
1314
- for (const file of memoryFiles) {
1350
+ const systemRulesDir = getResolvedRulesDir();
1351
+ if (fs.existsSync(systemRulesDir)) {
1352
+ const ruleFiles = fs.readdirSync(systemRulesDir).filter(f => f.endsWith('.md') && f !== RULES_DOC_FILENAME);
1353
+ for (const file of ruleFiles) {
1315
1354
  const targetName = file === 'AGENTS.md' ? agentConfig.instructionsFile : file;
1316
1355
  const targetPath = path.join(agentDir, targetName);
1317
1356
  const status = getSymlinkStatus(targetPath);
@@ -1555,44 +1594,33 @@ export function syncResourcesToVersion(agent, version, selection, options = {})
1555
1594
  }
1556
1595
  }
1557
1596
  }
1558
- // Sync memory files
1559
- const memoryToSync = selection
1560
- ? resolveSelection(selection.memory, available.memory)
1561
- : available.memory;
1562
- if (memoryToSync.length > 0 && COMMANDS_CAPABLE_AGENTS.includes(agent)) {
1563
- const centralMemory = getResolvedRulesDir();
1564
- const projectMemoryDir = projectAgentsDir ? path.join(projectAgentsDir, 'rules') : null;
1565
- const userMemoryDir = getUserRulesDir();
1566
- const syncedMemory = [];
1567
- const agentSupportsImports = !!agentConfig.capabilities.memoryImports;
1568
- for (const mem of memoryToSync) {
1569
- const candidates = [
1570
- projectMemoryDir ? safeJoin(projectMemoryDir, `${mem}.md`) : null,
1571
- safeJoin(userMemoryDir, `${mem}.md`),
1572
- safeJoin(centralMemory, `${mem}.md`),
1573
- ...extraRepos.map((e) => safeJoin(path.join(e.dir, 'rules'), `${mem}.md`)),
1574
- ];
1575
- const srcFile = candidates.find((p) => p && fs.existsSync(p) && !fs.lstatSync(p).isSymbolicLink()) || null;
1576
- if (!srcFile)
1577
- continue;
1578
- const targetName = mem === 'AGENTS' ? agentConfig.instructionsFile : `${mem}.md`;
1597
+ // Sync rules — compose from layered subrules + active preset and write a
1598
+ // single inlined instruction file. No @-import expansion; no per-fragment
1599
+ // copies. Project rules are NOT synced into the version home — they are
1600
+ // composed into the workspace at agents-run time (see compileRulesForProject).
1601
+ const skipMemory = selection && (selection.memory === undefined || (Array.isArray(selection.memory) && selection.memory.length === 0));
1602
+ if (!skipMemory && COMMANDS_CAPABLE_AGENTS.includes(agent)) {
1603
+ try {
1604
+ // If selection.memory names a single preset, treat it as a one-shot
1605
+ // override; otherwise read the persisted active preset.
1606
+ const overridePreset = Array.isArray(selection?.memory) && selection.memory.length === 1 && selection.memory[0] !== 'AGENTS'
1607
+ ? selection.memory[0]
1608
+ : null;
1609
+ const preset = overridePreset || getActiveRulesPreset(agent, version);
1610
+ const composed = composeRulesFromState({ preset });
1611
+ const targetName = agentConfig.instructionsFile;
1579
1612
  const destFile = safeJoin(agentDir, targetName);
1613
+ fs.mkdirSync(path.dirname(destFile), { recursive: true });
1580
1614
  removePath(destFile);
1581
- // For the primary memory file (AGENTS.md), agents that don't natively
1582
- // resolve @-imports get a compiled (inlined) copy + sidecar manifest.
1583
- // Everything else (secondary memory files, @-capable agents) gets a
1584
- // straight copy.
1585
- if (mem === 'AGENTS' && !agentSupportsImports) {
1586
- compileMemoryForAgent(agent, version);
1587
- }
1588
- else {
1589
- fs.copyFileSync(srcFile, destFile);
1590
- }
1615
+ fs.writeFileSync(destFile, composed.content);
1591
1616
  result.memory.push(targetName);
1592
- syncedMemory.push(mem);
1617
+ // Track which preset materialized — surfaces in `agents rules list`.
1618
+ recordVersionResources(agent, version, 'memory', [composed.preset]);
1593
1619
  }
1594
- if (syncedMemory.length > 0) {
1595
- recordVersionResources(agent, version, 'memory', syncedMemory);
1620
+ catch (err) {
1621
+ // No rules.yaml yet, or a typo'd preset name. Don't fail the whole sync —
1622
+ // just leave the agent without a synced rules file.
1623
+ console.warn(`Skipping rules sync for ${agent}@${version}: ${err.message}`);
1596
1624
  }
1597
1625
  }
1598
1626
  // Apply permissions (if agent supports them).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.14.2",
3
+ "version": "1.14.4",
4
4
  "description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -16,7 +16,8 @@
16
16
  },
17
17
  "bin": {
18
18
  "agents": "dist/index.js",
19
- "ag": "dist/index.js"
19
+ "ag": "dist/index.js",
20
+ "browser": "dist/browser.js"
20
21
  },
21
22
  "files": [
22
23
  "dist/**/*.js",