@phnx-labs/agents-cli 1.14.3 → 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.
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Subagents resource handler.
3
+ *
4
+ * Subagents are YAML files stored in subagents/ directories across layers.
5
+ * Format is the same for all agents. Resolution order: project > user > system.
6
+ */
7
+ import * as fs from 'fs';
8
+ import * as path from 'path';
9
+ import * as yaml from 'yaml';
10
+ import { getProjectAgentsDir, getUserSubagentsDir, getSystemSubagentsDir, getEnabledExtraRepos, } from '../state.js';
11
+ /** Get layer directories for subagent resolution. */
12
+ function getLayerDirs(cwd) {
13
+ const projectDir = getProjectAgentsDir(cwd);
14
+ const extraRepos = getEnabledExtraRepos();
15
+ return {
16
+ system: path.join(getSystemSubagentsDir()),
17
+ user: getUserSubagentsDir(),
18
+ project: projectDir ? path.join(projectDir, 'subagents') : null,
19
+ extra: extraRepos.map((e) => path.join(e.dir, 'subagents')),
20
+ };
21
+ }
22
+ /** Map source directory to layer name. */
23
+ function dirToLayer(dir, dirs) {
24
+ if (dirs.project && dir.startsWith(dirs.project))
25
+ return 'project';
26
+ if (dir.startsWith(dirs.user))
27
+ return 'user';
28
+ return 'system';
29
+ }
30
+ /** Parse a subagent YAML file and return parsed item. */
31
+ function parseSubagentYaml(filePath) {
32
+ if (!fs.existsSync(filePath)) {
33
+ return null;
34
+ }
35
+ try {
36
+ const content = fs.readFileSync(filePath, 'utf-8');
37
+ const parsed = yaml.parse(content);
38
+ if (!parsed || typeof parsed !== 'object') {
39
+ return null;
40
+ }
41
+ return {
42
+ name: parsed.name || '',
43
+ description: parsed.description || '',
44
+ model: parsed.model,
45
+ color: parsed.color,
46
+ config: parsed.config,
47
+ };
48
+ }
49
+ catch {
50
+ return null;
51
+ }
52
+ }
53
+ /** Extract name from filename (removes .yaml/.yml extension). */
54
+ function nameFromFile(filename) {
55
+ return filename.replace(/\.ya?ml$/, '');
56
+ }
57
+ export class SubagentsHandler {
58
+ kind = 'subagent';
59
+ /**
60
+ * List all subagents across layers, with higher layer winning on name conflict.
61
+ * Returns a union of all subagents, deduplicated by name.
62
+ */
63
+ listAll(_agent, cwd) {
64
+ const dirs = getLayerDirs(cwd);
65
+ const seen = new Set();
66
+ const results = [];
67
+ // Order: project > user > system > extra (extra comes last after system)
68
+ const layerDirs = [];
69
+ if (dirs.project && fs.existsSync(dirs.project)) {
70
+ layerDirs.push({ dir: dirs.project, layer: 'project' });
71
+ }
72
+ if (fs.existsSync(dirs.user)) {
73
+ layerDirs.push({ dir: dirs.user, layer: 'user' });
74
+ }
75
+ if (fs.existsSync(dirs.system)) {
76
+ layerDirs.push({ dir: dirs.system, layer: 'system' });
77
+ }
78
+ for (const extraDir of dirs.extra) {
79
+ if (fs.existsSync(extraDir)) {
80
+ layerDirs.push({ dir: extraDir, layer: 'system' });
81
+ }
82
+ }
83
+ for (const { dir, layer } of layerDirs) {
84
+ let entries;
85
+ try {
86
+ entries = fs.readdirSync(dir, { withFileTypes: true });
87
+ }
88
+ catch {
89
+ continue;
90
+ }
91
+ for (const entry of entries) {
92
+ if (!entry.isFile())
93
+ continue;
94
+ if (!entry.name.endsWith('.yaml') && !entry.name.endsWith('.yml'))
95
+ continue;
96
+ if (entry.name.startsWith('.'))
97
+ continue;
98
+ const name = nameFromFile(entry.name);
99
+ if (seen.has(name))
100
+ continue;
101
+ const filePath = path.join(dir, entry.name);
102
+ const item = parseSubagentYaml(filePath);
103
+ if (!item)
104
+ continue;
105
+ seen.add(name);
106
+ results.push({
107
+ name,
108
+ item,
109
+ layer,
110
+ path: filePath,
111
+ });
112
+ }
113
+ }
114
+ return results;
115
+ }
116
+ /**
117
+ * Resolve a single subagent by name.
118
+ * Returns the winning layer's version, or null if not found.
119
+ */
120
+ resolve(_agent, name, cwd) {
121
+ const dirs = getLayerDirs(cwd);
122
+ // Order: project > user > system > extra
123
+ const searchDirs = [];
124
+ if (dirs.project) {
125
+ searchDirs.push({ dir: dirs.project, layer: 'project' });
126
+ }
127
+ searchDirs.push({ dir: dirs.user, layer: 'user' });
128
+ searchDirs.push({ dir: dirs.system, layer: 'system' });
129
+ for (const extraDir of dirs.extra) {
130
+ searchDirs.push({ dir: extraDir, layer: 'system' });
131
+ }
132
+ for (const { dir, layer } of searchDirs) {
133
+ if (!fs.existsSync(dir))
134
+ continue;
135
+ // Try .yaml first, then .yml
136
+ for (const ext of ['.yaml', '.yml']) {
137
+ const filePath = path.join(dir, name + ext);
138
+ if (fs.existsSync(filePath)) {
139
+ const item = parseSubagentYaml(filePath);
140
+ if (item) {
141
+ return {
142
+ name,
143
+ item,
144
+ layer,
145
+ path: filePath,
146
+ };
147
+ }
148
+ }
149
+ }
150
+ }
151
+ return null;
152
+ }
153
+ /**
154
+ * Sync resolved subagents to the agent's version home directory.
155
+ * Copies YAML files to the target directory.
156
+ */
157
+ sync(agent, versionHome, cwd) {
158
+ const targetDir = path.join(versionHome, this.targetDir(agent));
159
+ // Ensure target directory exists
160
+ if (!fs.existsSync(targetDir)) {
161
+ fs.mkdirSync(targetDir, { recursive: true });
162
+ }
163
+ // Clear existing subagents in target
164
+ try {
165
+ const existing = fs.readdirSync(targetDir);
166
+ for (const file of existing) {
167
+ if (file.endsWith('.yaml') || file.endsWith('.yml')) {
168
+ fs.unlinkSync(path.join(targetDir, file));
169
+ }
170
+ }
171
+ }
172
+ catch {
173
+ // Ignore errors during cleanup
174
+ }
175
+ // Copy all resolved subagents
176
+ const resolved = this.listAll(agent, cwd);
177
+ for (const { name, path: sourcePath } of resolved) {
178
+ const ext = sourcePath.endsWith('.yml') ? '.yml' : '.yaml';
179
+ const targetPath = path.join(targetDir, name + ext);
180
+ fs.copyFileSync(sourcePath, targetPath);
181
+ }
182
+ }
183
+ /**
184
+ * Get the file format this resource uses for a given agent.
185
+ * Subagents always use YAML format.
186
+ */
187
+ format(_agent) {
188
+ return 'yaml';
189
+ }
190
+ /**
191
+ * Get the target directory name in the agent's version home.
192
+ */
193
+ targetDir(_agent) {
194
+ return 'subagents';
195
+ }
196
+ }
197
+ /** Singleton instance of the SubagentsHandler. */
198
+ export const subagentsHandler = new SubagentsHandler();
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Unified resource system types.
3
+ *
4
+ * Resources merge from three layers: system → user → project
5
+ * - Union: All resources from all layers are combined
6
+ * - Override on name conflict: Higher layer wins (project > user > system)
7
+ */
8
+ export type AgentId = 'claude' | 'codex' | 'gemini' | 'cursor' | 'opencode' | 'openclaw';
9
+ export type Layer = 'system' | 'user' | 'project';
10
+ export type ResourceKind = 'command' | 'hook' | 'skill' | 'rule' | 'mcp' | 'permission' | 'subagent';
11
+ /** A resolved resource with its origin layer. */
12
+ export interface ResolvedItem<T> {
13
+ name: string;
14
+ item: T;
15
+ layer: Layer;
16
+ path: string;
17
+ }
18
+ /**
19
+ * Resource handler interface.
20
+ *
21
+ * Each resource type (commands, hooks, skills, etc.) implements this interface
22
+ * to provide consistent list/resolve/sync behavior across all agent types.
23
+ */
24
+ export interface ResourceHandler<T> {
25
+ readonly kind: ResourceKind;
26
+ /**
27
+ * List all resources across layers, with higher layer winning on name conflict.
28
+ * Returns a union of all resources, deduplicated by name.
29
+ */
30
+ listAll(agent: AgentId, cwd?: string): ResolvedItem<T>[];
31
+ /**
32
+ * Resolve a single resource by name.
33
+ * Returns the winning layer's version, or null if not found.
34
+ */
35
+ resolve(agent: AgentId, name: string, cwd?: string): ResolvedItem<T> | null;
36
+ /**
37
+ * Sync resolved resources to the agent's version home directory.
38
+ * Copies/transforms resources as needed for the agent's expected format.
39
+ */
40
+ sync(agent: AgentId, versionHome: string, cwd?: string): void;
41
+ /**
42
+ * Get the file format this resource uses for a given agent.
43
+ */
44
+ format(agent: AgentId): 'md' | 'toml' | 'json' | 'yaml';
45
+ /**
46
+ * Get the target directory name in the agent's version home.
47
+ */
48
+ targetDir(agent: AgentId): string;
49
+ /**
50
+ * For resources that modify config files (MCP, permissions),
51
+ * return the config file path. Returns null if not applicable.
52
+ */
53
+ configPath?(agent: AgentId, versionHome: string): string | null;
54
+ /**
55
+ * Compute content hash for a resource item (for change detection).
56
+ * Used by diff() to detect modifications without full content comparison.
57
+ * Optional — handlers that don't implement this fall back to full sync.
58
+ */
59
+ hash?(item: T): string;
60
+ /**
61
+ * Compare source layers vs synced target to detect drift.
62
+ * Returns list of resources that differ (added, modified, removed).
63
+ * Enables incremental sync and "X resources out of sync" status.
64
+ * Optional — handlers that don't implement this always report "unknown".
65
+ */
66
+ diff?(agent: AgentId, versionHome: string, cwd?: string): ResourceDiff[];
67
+ }
68
+ /** Result of comparing source vs target for a single resource. */
69
+ export interface ResourceDiff {
70
+ name: string;
71
+ status: 'added' | 'modified' | 'removed';
72
+ sourceLayer: Layer | null;
73
+ sourceHash: string | null;
74
+ targetHash: string | null;
75
+ }
76
+ /** Helper to get layer directories for resource resolution. */
77
+ export interface LayerDirs {
78
+ system: string;
79
+ user: string;
80
+ project: string | null;
81
+ extra: string[];
82
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Unified resource system types.
3
+ *
4
+ * Resources merge from three layers: system → user → project
5
+ * - Union: All resources from all layers are combined
6
+ * - Override on name conflict: Higher layer wins (project > user > system)
7
+ */
8
+ export {};
package/dist/lib/state.js CHANGED
@@ -40,13 +40,13 @@ const SYSTEM_INSTRUCTIONS_FILE = path.join(SYSTEM_AGENTS_DIR, 'instructions.md')
40
40
  // User-level operational state
41
41
  const PACKAGES_DIR = path.join(USER_AGENTS_DIR, 'packages');
42
42
  const ROUTINES_DIR = path.join(USER_AGENTS_DIR, 'routines');
43
- const RUNS_DIR = path.join(USER_AGENTS_DIR, 'runs');
43
+ const RUNS_DIR = path.join(ROUTINES_DIR, 'runs');
44
44
  const VERSIONS_DIR = path.join(USER_AGENTS_DIR, 'versions');
45
45
  const SHIMS_DIR = path.join(USER_AGENTS_DIR, 'shims');
46
- const BACKUPS_DIR = path.join(USER_AGENTS_DIR, 'backups');
46
+ const BACKUPS_DIR = path.join(USER_AGENTS_DIR, '.backups');
47
47
  const PLUGINS_DIR = path.join(USER_AGENTS_DIR, 'plugins');
48
48
  const DRIVE_DIR = path.join(USER_AGENTS_DIR, 'drive');
49
- const TRASH_DIR = path.join(USER_AGENTS_DIR, 'trash');
49
+ const TRASH_DIR = path.join(USER_AGENTS_DIR, '.trash');
50
50
  // ─── User resource dirs ───────────────────────────────────────────────────────
51
51
  const USER_COMMANDS_DIR = path.join(USER_AGENTS_DIR, 'commands');
52
52
  const USER_HOOKS_DIR = path.join(USER_AGENTS_DIR, 'hooks');
@@ -264,8 +264,6 @@ export function ensureAgentsDir() {
264
264
  fs.mkdirSync(SYSTEM_PERMISSIONS_DIR, opts);
265
265
  if (!fs.existsSync(SYSTEM_SUBAGENTS_DIR))
266
266
  fs.mkdirSync(SYSTEM_SUBAGENTS_DIR, opts);
267
- if (!fs.existsSync(DRIVE_DIR))
268
- fs.mkdirSync(DRIVE_DIR, opts);
269
267
  try {
270
268
  fs.chmodSync(SYSTEM_AGENTS_DIR, 0o700);
271
269
  }
@@ -3,6 +3,8 @@ export interface TeamMeta {
3
3
  created_at: string;
4
4
  description?: string;
5
5
  enable_worktrees?: boolean;
6
+ /** Shared worktree path for all teammates (mutually exclusive with enable_worktrees). */
7
+ use_worktree?: string;
6
8
  }
7
9
  /** Map of team name to team metadata. */
8
10
  export type TeamRegistry = Record<string, TeamMeta>;
@@ -16,6 +18,8 @@ export declare function loadTeams(): Promise<TeamRegistry>;
16
18
  export interface CreateTeamOptions {
17
19
  description?: string;
18
20
  enableWorktrees?: boolean;
21
+ /** Path to an existing worktree for all teammates to share. */
22
+ useWorktree?: string;
19
23
  }
20
24
  /** Create a new team. Throws if a team with the same name already exists. */
21
25
  export declare function createTeam(name: string, options?: CreateTeamOptions): Promise<TeamMeta>;
@@ -92,6 +92,9 @@ async function saveTeams(reg) {
92
92
  }
93
93
  /** Create a new team. Throws if a team with the same name already exists. */
94
94
  export async function createTeam(name, options) {
95
+ if (options?.enableWorktrees && options?.useWorktree) {
96
+ throw new Error('Cannot use both --enable-worktrees and --use-worktree. Pick one.');
97
+ }
95
98
  const p = await registryPath();
96
99
  return withRegistryLock(p, async () => {
97
100
  const reg = await loadTeams();
@@ -102,6 +105,7 @@ export async function createTeam(name, options) {
102
105
  created_at: new Date().toISOString(),
103
106
  ...(options?.description ? { description: options.description } : {}),
104
107
  ...(options?.enableWorktrees ? { enable_worktrees: true } : {}),
108
+ ...(options?.useWorktree ? { use_worktree: options.useWorktree } : {}),
105
109
  };
106
110
  reg[name] = meta;
107
111
  await saveTeams(reg);
@@ -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.
@@ -27,7 +27,7 @@ import { getVersionsDir, ensureAgentsDir, readMeta, writeMeta, getCommandsDir, g
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';
@@ -145,17 +145,18 @@ export function getAvailableResources(cwd = process.cwd()) {
145
145
  }
146
146
  }
147
147
  result.memory = Array.from(presetNames);
148
- // MCP servers (*.yaml files)
148
+ // MCP servers (*.yaml files) — use the `name:` field inside, not filename
149
149
  const mcpNames = new Set();
150
150
  for (const { base } of resourceBases) {
151
151
  const mcpDir = path.join(base, 'mcp');
152
152
  if (!fs.existsSync(mcpDir))
153
153
  continue;
154
- const names = fs.readdirSync(mcpDir)
155
- .filter(f => f.endsWith('.yaml') || f.endsWith('.yml'))
156
- .map(f => f.replace(/\.(yaml|yml)$/, ''));
157
- for (const name of names) {
158
- 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);
159
160
  }
160
161
  }
161
162
  result.mcp = Array.from(mcpNames);
@@ -459,7 +460,11 @@ export function getNewResources(available, actuallySynced) {
459
460
  commands: available.commands.filter(c => !actuallySynced.commands.includes(c)),
460
461
  skills: available.skills.filter(s => !actuallySynced.skills.includes(s)),
461
462
  hooks: available.hooks.filter(h => !actuallySynced.hooks.includes(h)),
462
- 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)),
463
468
  mcp: available.mcp.filter(m => !actuallySynced.mcp.includes(m)),
464
469
  permissions: available.permissions.filter(p => !actuallySynced.permissions.includes(p)),
465
470
  subagents: available.subagents.filter(s => !actuallySynced.subagents.includes(s)),
@@ -471,14 +476,15 @@ export function getNewResources(available, actuallySynced) {
471
476
  }
472
477
  /**
473
478
  * Check if there are any new resources to sync.
479
+ * When version is provided, uses version-specific capability checks.
474
480
  */
475
- export function hasNewResources(diff, agent) {
476
- const commandsApply = agent ? COMMANDS_CAPABLE_AGENTS.includes(agent) : true;
477
- const hooksApply = agent ? AGENTS[agent].supportsHooks : true;
478
- const mcpApply = agent ? MCP_CAPABLE_AGENTS.includes(agent) : true;
479
- 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;
480
486
  const subagentsApply = agent ? SUBAGENT_CAPABLE_AGENTS.includes(agent) : true;
481
- const pluginsApply = agent ? PLUGINS_CAPABLE_AGENTS.includes(agent) : true;
487
+ const pluginsApply = agent ? supports(agent, 'plugins', version).ok : true;
482
488
  return ((diff.commands.length > 0 && commandsApply) ||
483
489
  diff.skills.length > 0 ||
484
490
  (diff.hooks.length > 0 && hooksApply) ||
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.14.3",
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",