@phnx-labs/agents-cli 1.22.9 → 1.22.11

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 (47) hide show
  1. package/CHANGELOG.md +37 -4
  2. package/dist/bin/agents +0 -0
  3. package/dist/commands/beta.js +21 -9
  4. package/dist/commands/browser.js +2 -2
  5. package/dist/commands/events.d.ts +1 -1
  6. package/dist/commands/events.js +1 -1
  7. package/dist/commands/projects.d.ts +1 -2
  8. package/dist/commands/projects.js +39 -47
  9. package/dist/commands/secrets.js +38 -39
  10. package/dist/commands/ssh.js +4 -5
  11. package/dist/commands/workflows.js +1 -1
  12. package/dist/index.js +1 -1
  13. package/dist/lib/beta.d.ts +1 -1
  14. package/dist/lib/beta.js +1 -1
  15. package/dist/lib/event-stream.d.ts +1 -1
  16. package/dist/lib/event-stream.js +1 -1
  17. package/dist/lib/events.d.ts +1 -1
  18. package/dist/lib/events.js +3 -3
  19. package/dist/lib/feed-broadcast.d.ts +19 -5
  20. package/dist/lib/feed-broadcast.js +41 -8
  21. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  22. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  23. package/dist/lib/migrate.js +61 -0
  24. package/dist/lib/plugins.d.ts +14 -5
  25. package/dist/lib/plugins.js +29 -5
  26. package/dist/lib/project-probe.d.ts +1 -1
  27. package/dist/lib/project-probe.js +1 -1
  28. package/dist/lib/projects.d.ts +1 -1
  29. package/dist/lib/resources/types.d.ts +2 -1
  30. package/dist/lib/resources/workflows.d.ts +1 -1
  31. package/dist/lib/resources/workflows.js +22 -20
  32. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  33. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  34. package/dist/lib/secrets/audit.js +1 -1
  35. package/dist/lib/secrets/bundles.d.ts +9 -0
  36. package/dist/lib/secrets/bundles.js +9 -23
  37. package/dist/lib/secrets/headless.d.ts +3 -15
  38. package/dist/lib/secrets/headless.js +5 -33
  39. package/dist/lib/secrets/index.d.ts +8 -0
  40. package/dist/lib/secrets/index.js +16 -36
  41. package/dist/lib/share/config.js +5 -7
  42. package/dist/lib/state.d.ts +1 -1
  43. package/dist/lib/state.js +1 -1
  44. package/dist/lib/types.d.ts +7 -1
  45. package/dist/lib/workflows.d.ts +19 -4
  46. package/dist/lib/workflows.js +81 -7
  47. package/package.json +1 -1
@@ -5,22 +5,13 @@
5
5
  * importing bundles.ts (which already imports index.ts).
6
6
  */
7
7
  /**
8
- * True when the current process is a background / non-interactive context that
9
- * must NEVER raise a Keychain biometry prompt on the interactive user's screen —
10
- * a prompt nobody is watching. Two signals, either sufficient:
8
+ * True when the current process was structurally launched by an agent runtime
9
+ * that must NEVER raise a Keychain biometry prompt on the user's screen:
11
10
  * - `AGENTS_RUNTIME` is `headless`, `teams`, or `terminal` — i.e. ANY agent
12
11
  * launch, interactive included, and inherited by everything spawned beneath
13
12
  * one (set on the child env by `agents run --headless`, scheduled routines,
14
13
  * teammates, and interactive runs — see exec.ts:430, runner.ts,
15
14
  * teams/agents.ts).
16
- * - neither stdin nor stdout is a TTY (a detached/backgrounded task whose
17
- * stdio is redirected to a log — e.g. a release script run in the
18
- * background as `( ... ) >log 2>&1 </dev/null`).
19
- * `AGENTS_SECRETS_NO_PROMPT=1` forces headless-safe; `=0` force-allows a prompt
20
- * even in a non-TTY context. An `eval "$(agents secrets export X)"` typed in a
21
- * PLAIN shell has no AGENTS_RUNTIME, so it is not classified headless and still
22
- * prompts. Run beneath an agent it inherits AGENTS_RUNTIME and resolves
23
- * broker-only — the agent, not the human, is the caller there.
24
15
  *
25
16
  * Only **macOS keychain** reads pop an interactive Touch ID sheet — the secrets
26
17
  * broker itself is a no-op off darwin (see agent.ts), and libsecret (Linux) /
@@ -33,31 +24,12 @@
33
24
  * fast with an actionable error instead of hijacking Touch ID. This generalizes
34
25
  * the per-caller broker-only pattern used across the headless secrets readers.
35
26
  */
36
- export function isHeadlessSecretsContext(env = process.env, platform = process.platform,
37
- // Injected so the TTY branch below is testable: it is the branch that decides a
38
- // plain human shell still prompts, which is this guard's entire safety argument,
39
- // and reading process.* directly made it unreachable from a test.
40
- tty = { stdin: process.stdin.isTTY, stdout: process.stdout.isTTY }) {
27
+ export function isHeadlessSecretsContext(env = process.env, platform = process.platform) {
41
28
  if (platform !== 'darwin')
42
29
  return false; // no biometry prompt to suppress off-darwin
43
- const override = env.AGENTS_SECRETS_NO_PROMPT;
44
- if (override === '1')
45
- return true;
46
- if (override === '0')
47
- return false;
48
- // Every AGENT-LAUNCH runtime resolves broker-only, interactive included.
49
- // `terminal` was missing, which made an agent terminal the one launch path
50
- // still allowed to pop Touch ID: exec.ts sets AGENTS_RUNTIME='terminal' for an
51
- // interactive run (exec.ts:430), that fell through to the TTY check below, and
52
- // a TTY meant "a human is watching, so prompting is fine". It is not fine —
53
- // opening a terminal is not a request to authenticate, and a launch that needs
54
- // a locked bundle should say so and point at `agents secrets unlock`, not grab
55
- // the fingerprint sensor. AGENTS_RUNTIME is INHERITED by everything spawned under
56
- // an agent, so `agents secrets export` run beneath one resolves broker-only too —
57
- // correctly: there the agent, not the human, is the caller. A plain shell carries
58
- // no AGENTS_RUNTIME, so a person running it themselves still gets the sheet.
30
+ // Every agent-launch runtime resolves broker-only, interactive included.
59
31
  const runtime = env.AGENTS_RUNTIME;
60
32
  if (runtime === 'headless' || runtime === 'teams' || runtime === 'terminal')
61
33
  return true;
62
- return !tty.stdin && !tty.stdout;
34
+ return false;
63
35
  }
@@ -143,6 +143,14 @@ export declare function healHmacKeyNoAclOnce(rec: HmacKeyRecord): boolean;
143
143
  * see readAndResolveBundleEnv.
144
144
  */
145
145
  export declare function keychainServiceAlias(item: string): string;
146
+ /**
147
+ * One-shot per process: activate hashing on machines with nothing to move,
148
+ * finish a crash-interrupted delete phase (silent), and run the interactive
149
+ * one-time re-key when cleartext-named items exist and a human is present.
150
+ * Never throws — a failed attempt leaves the process on cleartext names
151
+ * (exact pre-#316 behavior) and the next process retries.
152
+ */
153
+ export declare function maybeAutoRekey(): void;
146
154
  /** One re-keyed (or failed) item, by its old cleartext service name. */
147
155
  export interface RekeyPlanItem {
148
156
  oldService: string;
@@ -247,30 +247,7 @@ export function readHmacKeyRecord() {
247
247
  catch {
248
248
  return null;
249
249
  }
250
- const record = parseHmacKeyRecord(raw);
251
- // Converge a stale-ACL'd hmackey to silent, on the HOT read path. An old helper
252
- // (pre the metadata/hmackey no-ACL migration fix) re-stamped this
253
- // contractually-no-ACL item with a biometry ACL, so the read just above pops the
254
- // generic "Agents CLI needs to authenticate" sheet on EVERY hashed lookup — the
255
- // `agents devices list` stats probe the SessionStart hook runs, and every other
256
- // background hashed read. `maybeAutoRekey`'s one-shot heal only fires on a
257
- // cleartext-bundle resolve and is bypassed for the hmackey/hashed-name path
258
- // (prepareServiceName returns early for HMAC_KEY_ITEM before maybeAutoRekey), so
259
- // it never converged exactly these reads and the machine prompted forever.
260
- // Re-store the record no-ACL once per machine here (the read that produced it has
261
- // already happened — and already prompted if the item was ACL'd); every
262
- // subsequent read, in this process and all future ones, is silent.
263
- if (record && !record.healedNoAcl) {
264
- try {
265
- healHmacKeyNoAclOnce(record);
266
- record.healedNoAcl = true;
267
- }
268
- catch {
269
- // A failed no-ACL re-store leaves the item still ACL'd (a still-prompting
270
- // read) rather than a silent wrong state; the next process retries.
271
- }
272
- }
273
- return record;
250
+ return parseHmacKeyRecord(raw);
274
251
  }
275
252
  function writeHmacKeyRecord(rec) {
276
253
  // JSON.stringify drops undefined fields (used to clear pendingDeletes).
@@ -325,15 +302,13 @@ function resolveHashState() {
325
302
  export function keychainServiceAlias(item) {
326
303
  return prepareServiceName(item);
327
304
  }
328
- function prepareServiceName(item, opts) {
305
+ function prepareServiceName(item) {
329
306
  if (rawScopeDepth > 0)
330
307
  return item;
331
308
  if (!isOurItem(item))
332
309
  return item;
333
310
  if (item === HMAC_KEY_ITEM || item.startsWith(HASHED_SERVICE_PREFIX))
334
311
  return item;
335
- if (opts?.autoRekey)
336
- maybeAutoRekey();
337
312
  const st = resolveHashState();
338
313
  if (!st.active || !st.key)
339
314
  return item;
@@ -354,7 +329,6 @@ function prepareListPrefix(prefix) {
354
329
  return { prefix };
355
330
  if (prefix.startsWith(HASHED_SERVICE_PREFIX))
356
331
  return { prefix };
357
- maybeAutoRekey();
358
332
  const st = resolveHashState();
359
333
  if (!st.active || !st.key)
360
334
  return { prefix };
@@ -429,7 +403,7 @@ function finishPendingDeletes(rec) {
429
403
  * Never throws — a failed attempt leaves the process on cleartext names
430
404
  * (exact pre-#316 behavior) and the next process retries.
431
405
  */
432
- function maybeAutoRekey() {
406
+ export function maybeAutoRekey() {
433
407
  if (autoRekeyAttempted || rekeyRunning || rawScopeDepth > 0)
434
408
  return;
435
409
  autoRekeyAttempted = true;
@@ -446,10 +420,14 @@ function maybeAutoRekey() {
446
420
  return;
447
421
  const st = resolveHashState();
448
422
  if (st.active) {
449
- // The stale-ACL'd-hmackey heal now runs on the hot read path
450
- // (readHmacKeyRecord), which the resolveHashState() above just went through —
451
- // so st.record is already healed here regardless of how this machine reached
452
- // "hashing active". Nothing to do but finish any pending deletes.
423
+ if (st.record && !st.record.healedNoAcl) {
424
+ try {
425
+ healHmacKeyNoAclOnce(st.record);
426
+ }
427
+ catch {
428
+ /* the next explicit unlock retries */
429
+ }
430
+ }
453
431
  if (st.record?.pendingDeletes?.length) {
454
432
  try {
455
433
  finishPendingDeletes(st.record);
@@ -820,7 +798,7 @@ export function getKeychainToken(item, context = {}) {
820
798
  // Errors keep the requested (human-readable) name; the storage name may be
821
799
  // an opaque hash.
822
800
  const requested = item;
823
- item = prepareServiceName(item, { autoRekey: true });
801
+ item = prepareServiceName(item);
824
802
  if (backend)
825
803
  return backend.get(item);
826
804
  assertRawKeychainReadAllowed(requested, context);
@@ -848,6 +826,7 @@ export function getKeychainToken(item, context = {}) {
848
826
  ...process.env,
849
827
  AGENTS_KEYCHAIN_PROMPT: keychainOperationPrompt(context),
850
828
  AGENTS_KEYCHAIN_PROMPT_BASE: keychainOperationPrompt({ ...context, duration: undefined }),
829
+ AGENTS_KEYCHAIN_SKIP_AUTH_UI: context.silentNoAcl ? '1' : '0',
851
830
  },
852
831
  stdio: ['ignore', 'pipe', 'pipe'],
853
832
  });
@@ -890,7 +869,7 @@ export function getKeychainTokens(items, context = {}) {
890
869
  // whether those were cleartext (hashed here) or already-hashed (enumerated).
891
870
  const requestedByStorage = new Map();
892
871
  const storageItems = items.map((item) => {
893
- const storage = prepareServiceName(item, { autoRekey: true });
872
+ const storage = prepareServiceName(item);
894
873
  if (!requestedByStorage.has(storage))
895
874
  requestedByStorage.set(storage, item);
896
875
  return storage;
@@ -939,6 +918,7 @@ export function getKeychainTokens(items, context = {}) {
939
918
  ...process.env,
940
919
  AGENTS_KEYCHAIN_PROMPT: keychainOperationPrompt(context),
941
920
  AGENTS_KEYCHAIN_PROMPT_BASE: keychainOperationPrompt({ ...context, duration: undefined }),
921
+ AGENTS_KEYCHAIN_SKIP_AUTH_UI: context.silentNoAcl ? '1' : '0',
942
922
  // The signed helper's own vocabulary is unchanged (it predates the rename
943
923
  // and ships as a separately-versioned binary), so map to its legacy token.
944
924
  AGENTS_KEYCHAIN_DEFAULT_POLICY: (context.defaultPolicy ?? 'hold') === 'hold' ? 'daily' : context.defaultPolicy,
@@ -1037,7 +1017,7 @@ export function setKeychainToken(item, value, opts) {
1037
1017
  if (/[\x00=\r\n]/.test(item))
1038
1018
  throw new Error('Secret item name contains invalid characters.');
1039
1019
  const requested = item;
1040
- item = prepareServiceName(item, { autoRekey: true });
1020
+ item = prepareServiceName(item);
1041
1021
  if (backend) {
1042
1022
  backend.set(item, value, opts);
1043
1023
  return;
@@ -9,7 +9,7 @@
9
9
  // `cloudflare` bundle.
10
10
  import { randomBytes } from 'node:crypto';
11
11
  import { readMeta, updateMeta } from '../state.js';
12
- import { bundleExists, bundleItemStore, bundlePolicy, isHeadlessSecretsContext, keychainRef, readAndResolveBundleEnv, readBundle, writeBundle, } from '../secrets/bundles.js';
12
+ import { bundleExists, bundleItemStore, bundlePolicy, keychainRef, readAndResolveBundleEnv, readBundle, writeBundle, } from '../secrets/bundles.js';
13
13
  import { secretsKeychainItem } from '../secrets/index.js';
14
14
  export const SHARE_BUNDLE = 'share';
15
15
  export const SHARE_TOKEN_KEY = 'WRITE_TOKEN';
@@ -76,10 +76,8 @@ export function storeWriteToken(token) {
76
76
  export function readWriteTokenFromBundle() {
77
77
  const { env } = readAndResolveBundleEnv(SHARE_BUNDLE, {
78
78
  caller: 'share',
79
- // Explicit `agents share` command (a human published a file): a headless agent
80
- // subprocess resolves broker-only, an interactive human may unlock. This is NOT
81
- // an agent LAUNCH read (that is exec.ts's --secrets injection, always agentOnly).
82
- agentOnly: isHeadlessSecretsContext(),
79
+ // Explicit share commands are reads, not authorization to authenticate.
80
+ agentOnly: true,
83
81
  });
84
82
  const token = env[SHARE_TOKEN_KEY];
85
83
  if (!token) {
@@ -136,8 +134,8 @@ export function readCloudflareCreds(bundle = DEFAULT_CF_BUNDLE, override) {
136
134
  }
137
135
  const { env } = readAndResolveBundleEnv(bundle, {
138
136
  caller: 'share',
139
- // Explicit `agents share setup` provisioning read — not an agent launch.
140
- agentOnly: isHeadlessSecretsContext(),
137
+ // Setup is still a read; only `agents secrets unlock` may authenticate.
138
+ agentOnly: true,
141
139
  });
142
140
  const find = (re) => {
143
141
  for (const [k, v] of Object.entries(env))
@@ -113,7 +113,7 @@ export declare function getUserSecretsDir(): string;
113
113
  * value-free usage telemetry (which bundle was created/imported/exported/viewed/
114
114
  * accessed/unlocked, when, by whom), never a secret value. It is a derived index
115
115
  * fed FROM the emitSecretAudit chokepoint alongside the append-only
116
- * ~/.agents/events.jsonl audit log — the same way sessions.db indexes session
116
+ * ~/.agents/.history/events/events.jsonl audit log — the same way sessions.db indexes session
117
117
  * metadata off the real session flow — not a second write path.
118
118
  */
119
119
  export declare function getSecretsDbPath(): string;
package/dist/lib/state.js CHANGED
@@ -372,7 +372,7 @@ export function getUserSecretsDir() { return USER_SECRETS_DIR; }
372
372
  * value-free usage telemetry (which bundle was created/imported/exported/viewed/
373
373
  * accessed/unlocked, when, by whom), never a secret value. It is a derived index
374
374
  * fed FROM the emitSecretAudit chokepoint alongside the append-only
375
- * ~/.agents/events.jsonl audit log — the same way sessions.db indexes session
375
+ * ~/.agents/.history/events/events.jsonl audit log — the same way sessions.db indexes session
376
376
  * metadata off the real session flow — not a second write path.
377
377
  */
378
378
  export function getSecretsDbPath() {
@@ -79,7 +79,7 @@ export interface BudgetConfig {
79
79
  require_confirm_over?: number;
80
80
  }
81
81
  /** Preview features that users can opt into via `agents beta`. */
82
- export type BetaFeatureName = 'factory' | 'projects';
82
+ export type BetaFeatureName = 'factory';
83
83
  /** Subset of chalk color names used for agent-specific terminal output. */
84
84
  export type ChalkColor = 'magenta' | 'green' | 'blue' | 'cyan' | 'yellowBright' | 'redBright' | 'whiteBright' | 'blueBright' | 'greenBright' | 'magentaBright' | 'cyanBright';
85
85
  /** Static configuration for a single agent -- paths, capabilities, and format conventions. */
@@ -610,6 +610,12 @@ export interface DiscoveredPlugin {
610
610
  commands: string[];
611
611
  /** Subagent .md files in the plugin's agents/ directory (names without extension). */
612
612
  agentDefs: string[];
613
+ /**
614
+ * Workflow directory names under the plugin's `workflows/` (each must contain
615
+ * WORKFLOW.md). Phase 5 packaging: plugins may package workflows as entrypoints;
616
+ * `agents run <name>` resolves them via project > user > plugin > extra > system.
617
+ */
618
+ workflows: string[];
613
619
  /** Memory fact basenames from the plugin's memory/ directory (without .md). */
614
620
  memory: string[];
615
621
  /** Executable files in the plugin's bin/ directory. */
@@ -312,11 +312,25 @@ export declare const GROK_WORKFLOW_MARKER = "agents_workflow";
312
312
  export declare function transformWorkflowForGrok(workflowPath: string, name: string): string;
313
313
  /** Read the agents_workflow marker from a Grok `.rhai` file, if present. */
314
314
  export declare function grokWorkflowMarker(filePath: string): string | null;
315
+ /**
316
+ * Plugin `workflows/` directories in discovery order (project → user → system →
317
+ * extra). Used by name resolution and listing so a plugin-packaged workflow is
318
+ * runnable via `agents run <name>` without a separate install into
319
+ * ~/.agents/workflows/ (Phase 5 packaging). Within the plugin band, project
320
+ * plugins beat user/system plugins (same first-hit-wins as other layers).
321
+ */
322
+ export declare function listPluginWorkflowDirs(cwd?: string): string[];
323
+ /**
324
+ * True when `ref` is a single bare workflow name (no path separators, no `..`).
325
+ * Name lookup must not path-join multi-segment or traversal refs into search roots.
326
+ */
327
+ export declare function isBareWorkflowName(ref: string): boolean;
315
328
  /**
316
329
  * Resolve an `agents run <workflow>` reference.
317
330
  *
318
331
  * Directories are accepted anywhere on disk when they contain WORKFLOW.md.
319
- * Name lookup keeps the normal resource precedence: project > user > system > extras.
332
+ * Name lookup precedence (docs/07-entrypoints): project > user > plugin > extra > system.
333
+ * Bare name only; `name@plugin` disambiguation is a follow-up.
320
334
  */
321
335
  export declare function resolveWorkflowRef(ref: string, cwd?: string): string | null;
322
336
  /**
@@ -326,10 +340,11 @@ export declare function resolveWorkflowRef(ref: string, cwd?: string): string |
326
340
  */
327
341
  export declare function discoverWorkflowsFromRepo(repoPath: string): DiscoveredWorkflow[];
328
342
  /**
329
- * List all workflows in central storage.
330
- * User layer (~/.agents/workflows/) wins over system (~/.agents/.system/workflows/).
343
+ * List all workflows in central storage + plugin packages.
344
+ * Precedence: user > plugin > extra > system (first writer wins; project is
345
+ * cwd-scoped and handled by resolveWorkflowRef / the resource handler).
331
346
  */
332
- export declare function listInstalledWorkflows(): Map<string, InstalledWorkflow>;
347
+ export declare function listInstalledWorkflows(cwd?: string): Map<string, InstalledWorkflow>;
333
348
  /** Copy a workflow directory into user central storage (~/.agents/workflows/<name>/). */
334
349
  export declare function installWorkflowCentrally(sourcePath: string, name: string): {
335
350
  success: boolean;
@@ -10,7 +10,7 @@ import * as os from 'os';
10
10
  import * as path from 'path';
11
11
  import * as yaml from 'yaml';
12
12
  import { capableAgents, supports } from './capabilities.js';
13
- import { getProjectAgentsDir, getSystemWorkflowsDir, getUserWorkflowsDir, getTrashWorkflowsDir, getEnabledExtraRepos, } from './state.js';
13
+ import { getProjectAgentsDir, getSystemWorkflowsDir, getUserWorkflowsDir, getTrashWorkflowsDir, getEnabledExtraRepos, getPluginsDir, getSystemPluginsDir, getProjectPluginsDir, } from './state.js';
14
14
  import { listInstalledVersions, getVersionHomePath } from './versions.js';
15
15
  /**
16
16
  * Hard upper bound on items a single `for_each` expands, absent an explicit
@@ -522,22 +522,94 @@ function resolveWorkflowPath(ref, cwd) {
522
522
  const candidate = path.isAbsolute(expanded) ? expanded : path.resolve(cwd, expanded);
523
523
  return isWorkflowDir(candidate) ? candidate : null;
524
524
  }
525
+ /**
526
+ * Plugin `workflows/` directories in discovery order (project → user → system →
527
+ * extra). Used by name resolution and listing so a plugin-packaged workflow is
528
+ * runnable via `agents run <name>` without a separate install into
529
+ * ~/.agents/workflows/ (Phase 5 packaging). Within the plugin band, project
530
+ * plugins beat user/system plugins (same first-hit-wins as other layers).
531
+ */
532
+ export function listPluginWorkflowDirs(cwd = process.cwd()) {
533
+ const pluginRoots = [];
534
+ const pluginsDirs = [];
535
+ const projectPlugins = getProjectPluginsDir(cwd);
536
+ if (projectPlugins)
537
+ pluginsDirs.push(projectPlugins);
538
+ pluginsDirs.push(getPluginsDir(), getSystemPluginsDir());
539
+ for (const extra of getEnabledExtraRepos()) {
540
+ pluginsDirs.push(path.join(extra.dir, 'plugins'));
541
+ }
542
+ for (const pluginsDir of pluginsDirs) {
543
+ if (!fs.existsSync(pluginsDir))
544
+ continue;
545
+ let entries;
546
+ try {
547
+ entries = fs.readdirSync(pluginsDir, { withFileTypes: true });
548
+ }
549
+ catch {
550
+ continue;
551
+ }
552
+ for (const entry of entries) {
553
+ if (entry.name.startsWith('.'))
554
+ continue;
555
+ // Directories and symlinks-to-directories (plugin marketplaces often symlink).
556
+ const pluginRoot = path.join(pluginsDir, entry.name);
557
+ let isDir = entry.isDirectory();
558
+ if (!isDir && entry.isSymbolicLink()) {
559
+ try {
560
+ isDir = fs.statSync(pluginRoot).isDirectory();
561
+ }
562
+ catch {
563
+ isDir = false;
564
+ }
565
+ }
566
+ if (!isDir)
567
+ continue;
568
+ const workflowsDir = path.join(pluginRoot, 'workflows');
569
+ if (fs.existsSync(workflowsDir))
570
+ pluginRoots.push(workflowsDir);
571
+ }
572
+ }
573
+ return pluginRoots;
574
+ }
575
+ /**
576
+ * True when `ref` is a single bare workflow name (no path separators, no `..`).
577
+ * Name lookup must not path-join multi-segment or traversal refs into search roots.
578
+ */
579
+ export function isBareWorkflowName(ref) {
580
+ if (!ref || ref === '.' || ref === '..')
581
+ return false;
582
+ if (ref.includes('/') || ref.includes('\\'))
583
+ return false;
584
+ if (ref.includes('..'))
585
+ return false;
586
+ // Reject absolute paths (posix or Windows).
587
+ if (path.isAbsolute(ref))
588
+ return false;
589
+ return path.basename(ref) === ref;
590
+ }
525
591
  /**
526
592
  * Resolve an `agents run <workflow>` reference.
527
593
  *
528
594
  * Directories are accepted anywhere on disk when they contain WORKFLOW.md.
529
- * Name lookup keeps the normal resource precedence: project > user > system > extras.
595
+ * Name lookup precedence (docs/07-entrypoints): project > user > plugin > extra > system.
596
+ * Bare name only; `name@plugin` disambiguation is a follow-up.
530
597
  */
531
598
  export function resolveWorkflowRef(ref, cwd = process.cwd()) {
532
599
  const direct = resolveWorkflowPath(ref, cwd);
533
600
  if (direct)
534
601
  return direct;
602
+ // Name lookup only — reject traversal / multi-segment so path.join(dir, ref)
603
+ // cannot escape a workflows root (absolute paths already handled above).
604
+ if (!isBareWorkflowName(ref))
605
+ return null;
535
606
  const projectAgentsDir = getProjectAgentsDir(cwd);
536
607
  const searchDirs = [
537
608
  ...(projectAgentsDir ? [path.join(projectAgentsDir, 'workflows')] : []),
538
609
  getUserWorkflowsDir(),
539
- getSystemWorkflowsDir(),
610
+ ...listPluginWorkflowDirs(cwd),
540
611
  ...getEnabledExtraRepos().map(r => path.join(r.dir, 'workflows')),
612
+ getSystemWorkflowsDir(),
541
613
  ];
542
614
  for (const dir of searchDirs) {
543
615
  const workflowPath = path.join(dir, ref);
@@ -592,16 +664,18 @@ export function discoverWorkflowsFromRepo(repoPath) {
592
664
  return results;
593
665
  }
594
666
  /**
595
- * List all workflows in central storage.
596
- * User layer (~/.agents/workflows/) wins over system (~/.agents/.system/workflows/).
667
+ * List all workflows in central storage + plugin packages.
668
+ * Precedence: user > plugin > extra > system (first writer wins; project is
669
+ * cwd-scoped and handled by resolveWorkflowRef / the resource handler).
597
670
  */
598
- export function listInstalledWorkflows() {
671
+ export function listInstalledWorkflows(cwd = process.cwd()) {
599
672
  const result = new Map();
600
673
  const extraRepos = getEnabledExtraRepos();
601
674
  const searchDirs = [
602
675
  getUserWorkflowsDir(),
603
- getSystemWorkflowsDir(),
676
+ ...listPluginWorkflowDirs(cwd),
604
677
  ...extraRepos.map(r => path.join(r.dir, 'workflows')),
678
+ getSystemWorkflowsDir(),
605
679
  ];
606
680
  for (const dir of searchDirs) {
607
681
  if (!fs.existsSync(dir))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.22.9",
3
+ "version": "1.22.11",
4
4
  "description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",