@actiondock/core 2.0.9 → 2.0.11-beta.0

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.
@@ -1,9 +1,39 @@
1
1
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
- import { join } from "node:path";
3
- import type { ActionDockManifest, ActionManifestEntry } from "./types";
2
+ import { join, relative } from "node:path";
3
+ import { loadActionFileMap, loadProjectConfig } from "./loader";
4
+ import type {
5
+ ActionDockManifest,
6
+ ActionManifestEntry,
7
+ ManifestSyncChange,
8
+ ManifestSyncResult,
9
+ SyncManifestOptions,
10
+ } from "./types";
4
11
 
5
12
  export const MANIFEST_FILE_NAME = "actiondock.manifest.json";
6
13
 
14
+ function deepEqual(a: unknown, b: unknown): boolean {
15
+ if (a === b) return true;
16
+ if (a == null || b == null) return a === b;
17
+ if (typeof a !== "object" || typeof b !== "object") return false;
18
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
19
+ if (Array.isArray(a)) {
20
+ const arrB = b as unknown[];
21
+ if (a.length !== arrB.length) return false;
22
+ for (let i = 0; i < a.length; i++) {
23
+ if (!deepEqual(a[i], arrB[i])) return false;
24
+ }
25
+ return true;
26
+ }
27
+ const keysA = Object.keys(a as Record<string, unknown>);
28
+ const keysB = Object.keys(b as Record<string, unknown>);
29
+ if (keysA.length !== keysB.length) return false;
30
+ for (const k of keysA) {
31
+ if (!Object.prototype.hasOwnProperty.call(b, k)) return false;
32
+ if (!deepEqual((a as Record<string, unknown>)[k], (b as Record<string, unknown>)[k])) return false;
33
+ }
34
+ return true;
35
+ }
36
+
7
37
  /**
8
38
  * 读取并解析项目的声明式清单文件。
9
39
  * 若文件不存在则返回 null。
@@ -94,3 +124,176 @@ export function createManifestEntry(options: {
94
124
  annotations: options.annotations,
95
125
  };
96
126
  }
127
+
128
+ /**
129
+ * 依据动作源码目录中的 Action 定义,增量同步或校验 actiondock.manifest.json 文件。
130
+ *
131
+ * @param projectRoot 项目根目录绝对路径
132
+ * @param options 同步选项(包括 actionsDir、check、prune、autoInstall)
133
+ * @returns 同步变更结果报告
134
+ */
135
+ export async function syncManifest(
136
+ projectRoot: string,
137
+ options: SyncManifestOptions = {}
138
+ ): Promise<ManifestSyncResult> {
139
+ const manifestPath = join(projectRoot, MANIFEST_FILE_NAME);
140
+
141
+ // 1. 确定 actions 目录
142
+ let actionsDir = options.actionsDir;
143
+ if (!actionsDir) {
144
+ try {
145
+ const config = loadProjectConfig(projectRoot);
146
+ actionsDir = config.actionsDir || "actions";
147
+ } catch {
148
+ actionsDir = "actions";
149
+ }
150
+ }
151
+
152
+ // 2. 加载现有清单,不存在则初始化基准结构
153
+ const existingManifest = loadManifest(projectRoot);
154
+ const manifest: ActionDockManifest = existingManifest
155
+ ? {
156
+ schemaVersion: existingManifest.schemaVersion || 1,
157
+ actions: { ...existingManifest.actions },
158
+ assets: existingManifest.assets ? [...existingManifest.assets] : [],
159
+ }
160
+ : {
161
+ schemaVersion: 1,
162
+ actions: {},
163
+ assets: [],
164
+ };
165
+
166
+ // 3. 动态加载所有 Action 源码定义
167
+ const actionFileMap = await loadActionFileMap(projectRoot, actionsDir, {
168
+ autoInstall: options.autoInstall !== false,
169
+ strict: true,
170
+ });
171
+
172
+ const changes: ManifestSyncChange[] = [];
173
+ const added: string[] = [];
174
+ const updated: string[] = [];
175
+ const removed: string[] = [];
176
+ const unchanged: string[] = [];
177
+
178
+ const scannedActionIds = new Set<string>();
179
+
180
+ // 4. 比对源码中的每个 Action 与现有清单项
181
+ for (const [actionId, fileEntry] of actionFileMap.entries()) {
182
+ scannedActionIds.add(actionId);
183
+ const relEntry = relative(projectRoot, fileEntry.filePath).replace(/\\/g, "/");
184
+ const act = fileEntry.action;
185
+
186
+ const newManifestEntry: ActionManifestEntry = {
187
+ entry: relEntry,
188
+ description: act.description ?? "",
189
+ inputSchema: act.inputSchema ?? {},
190
+ outputSchema: act.outputSchema ?? {},
191
+ uses: Array.isArray(act.uses) ? [...act.uses] : [],
192
+ tags: Array.isArray(act.tags) ? [...act.tags] : [],
193
+ };
194
+ if (act.annotations && typeof act.annotations === "object") {
195
+ newManifestEntry.annotations = act.annotations;
196
+ }
197
+
198
+ const oldManifestEntry = manifest.actions[actionId];
199
+
200
+ if (!oldManifestEntry) {
201
+ changes.push({
202
+ actionId,
203
+ type: "added",
204
+ entry: relEntry,
205
+ });
206
+ added.push(actionId);
207
+ if (!options.check) {
208
+ manifest.actions[actionId] = newManifestEntry;
209
+ }
210
+ } else {
211
+ const changedFields: string[] = [];
212
+
213
+ if (oldManifestEntry.entry !== newManifestEntry.entry) {
214
+ changedFields.push("entry");
215
+ }
216
+ if ((oldManifestEntry.description ?? "") !== (newManifestEntry.description ?? "")) {
217
+ changedFields.push("description");
218
+ }
219
+ if (!deepEqual(oldManifestEntry.inputSchema ?? {}, newManifestEntry.inputSchema ?? {})) {
220
+ changedFields.push("inputSchema");
221
+ }
222
+ if (!deepEqual(oldManifestEntry.outputSchema ?? {}, newManifestEntry.outputSchema ?? {})) {
223
+ changedFields.push("outputSchema");
224
+ }
225
+ if (!deepEqual(oldManifestEntry.uses ?? [], newManifestEntry.uses ?? [])) {
226
+ changedFields.push("uses");
227
+ }
228
+ if (!deepEqual(oldManifestEntry.tags ?? [], newManifestEntry.tags ?? [])) {
229
+ changedFields.push("tags");
230
+ }
231
+ if (!deepEqual(oldManifestEntry.annotations ?? {}, newManifestEntry.annotations ?? {})) {
232
+ changedFields.push("annotations");
233
+ }
234
+
235
+ if (changedFields.length > 0) {
236
+ changes.push({
237
+ actionId,
238
+ type: "updated",
239
+ entry: relEntry,
240
+ changedFields,
241
+ });
242
+ updated.push(actionId);
243
+ if (!options.check) {
244
+ manifest.actions[actionId] = newManifestEntry;
245
+ }
246
+ } else {
247
+ changes.push({
248
+ actionId,
249
+ type: "unchanged",
250
+ entry: relEntry,
251
+ });
252
+ unchanged.push(actionId);
253
+ }
254
+ }
255
+ }
256
+
257
+ // 5. 检查清单中存在但源码中已不存在的废弃动作
258
+ for (const [actionId, item] of Object.entries(manifest.actions)) {
259
+ if (!scannedActionIds.has(actionId)) {
260
+ changes.push({
261
+ actionId,
262
+ type: "removed",
263
+ entry: item.entry,
264
+ });
265
+ removed.push(actionId);
266
+ if (!options.check && options.prune !== false) {
267
+ delete manifest.actions[actionId];
268
+ }
269
+ }
270
+ }
271
+
272
+ const inSync = added.length === 0 && updated.length === 0 && removed.length === 0;
273
+
274
+ // 6. 如果存在变更且非仅检查模式,保存清单文件
275
+ if (!options.check && !inSync) {
276
+ saveManifest(projectRoot, manifest);
277
+ }
278
+
279
+ return {
280
+ inSync,
281
+ manifestPath,
282
+ changes,
283
+ added,
284
+ updated,
285
+ removed,
286
+ unchanged,
287
+ };
288
+ }
289
+
290
+ /**
291
+ * 校验当前 actiondock.manifest.json 是否与动作源码定义保持一致(只读检查)。
292
+ */
293
+ export async function checkManifestSync(
294
+ projectRoot: string,
295
+ options: Omit<SyncManifestOptions, "check"> = {}
296
+ ): Promise<ManifestSyncResult> {
297
+ return syncManifest(projectRoot, { ...options, check: true });
298
+ }
299
+
@@ -90,3 +90,52 @@ export interface ActionDockManifest {
90
90
  actions: Record<string, ActionManifestEntry>;
91
91
  assets?: string[];
92
92
  }
93
+
94
+ /**
95
+ * 清单同步操作选项。
96
+ */
97
+ export interface SyncManifestOptions {
98
+ /** 自定义 actions 源码目录(默认为工程配置中的 actionsDir 或 "actions") */
99
+ actionsDir?: string;
100
+ /** 是否仅检查同步状态而不保存写入文件(默认 false) */
101
+ check?: boolean;
102
+ /** 是否自动移除源码中已不存在的 Action(默认 true) */
103
+ prune?: boolean;
104
+ /** 缺失依赖时是否自动执行安装(默认 true) */
105
+ autoInstall?: boolean;
106
+ }
107
+
108
+ /**
109
+ * 单个 Action 的清单同步变更项。
110
+ */
111
+ export interface ManifestSyncChange {
112
+ /** Action 标识符 */
113
+ actionId: string;
114
+ /** 变更类型:新增、更新、删除或未变 */
115
+ type: "added" | "updated" | "removed" | "unchanged";
116
+ /** 源码入口文件相对路径 */
117
+ entry?: string;
118
+ /** 发生变更的字段列表(例如 ["description", "inputSchema"]) */
119
+ changedFields?: string[];
120
+ }
121
+
122
+ /**
123
+ * 清单同步执行结果。
124
+ */
125
+ export interface ManifestSyncResult {
126
+ /** 清单当前是否已与源码完全一致 */
127
+ inSync: boolean;
128
+ /** 清单文件的物理绝对路径 */
129
+ manifestPath: string;
130
+ /** 所有 Action 的变更详情列表 */
131
+ changes: ManifestSyncChange[];
132
+ /** 新增的 Action 标识列表 */
133
+ added: string[];
134
+ /** 更新的 Action 标识列表 */
135
+ updated: string[];
136
+ /** 移除的 Action 标识列表 */
137
+ removed: string[];
138
+ /** 未改变的 Action 标识列表 */
139
+ unchanged: string[];
140
+ }
141
+
@@ -1,6 +1,7 @@
1
- import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
2
2
  import { basename, dirname, join, resolve } from "node:path";
3
3
  import { findProjectRoot, loadActions, loadPlaybooks, loadProjectConfig } from "../project/loader";
4
+ import { loadManifest } from "../project/manifest";
4
5
  import { getActionDockHome, getPackageSlug } from "../utils";
5
6
  import type {
6
7
  GlobalRegistryData,
@@ -330,6 +331,166 @@ export function listLinkedWorkspaces(customHome?: string): LinkedWorkspaceEntry[
330
331
  return Object.values(registry.workspaces || {});
331
332
  }
332
333
 
334
+ async function projectHasAction(
335
+ projectRoot: string,
336
+ actionsDir: string | undefined,
337
+ actionId: string
338
+ ): Promise<boolean> {
339
+ const manifest = loadManifest(projectRoot);
340
+ if (manifest?.actions && actionId in manifest.actions) {
341
+ return true;
342
+ }
343
+ try {
344
+ const actions = await loadActions(projectRoot, actionsDir, { autoInstall: false });
345
+ return actions.has(actionId);
346
+ } catch {
347
+ return false;
348
+ }
349
+ }
350
+
351
+ function projectHasActionSync(
352
+ projectRoot: string,
353
+ actionsDir: string | undefined,
354
+ actionId: string
355
+ ): boolean {
356
+ const manifest = loadManifest(projectRoot);
357
+ if (manifest?.actions && actionId in manifest.actions) {
358
+ return true;
359
+ }
360
+ const dir = join(projectRoot, actionsDir || "actions");
361
+ if (!existsSync(dir)) {
362
+ return false;
363
+ }
364
+ if (actionId.includes("..") || actionId.startsWith("/") || actionId.startsWith("\\")) {
365
+ return false;
366
+ }
367
+ if (existsSync(join(dir, `${actionId}.ts`)) || existsSync(join(dir, `${actionId}.js`))) {
368
+ return true;
369
+ }
370
+ const relFile = actionId.replace(/\./g, "/");
371
+ if (existsSync(join(dir, `${relFile}.ts`)) || existsSync(join(dir, `${relFile}.js`))) {
372
+ return true;
373
+ }
374
+ return false;
375
+ }
376
+
377
+ export function resolveActionProjectSync(
378
+ actionIdentifier: string,
379
+ cwd: string = process.cwd(),
380
+ customHome?: string
381
+ ): ResolvedActionProject {
382
+ // 1. Check current directory / parent project
383
+ const currentRoot = findProjectRoot(cwd);
384
+ if (currentRoot) {
385
+ try {
386
+ const config = loadProjectConfig(currentRoot);
387
+ if (projectHasActionSync(currentRoot, config.actionsDir, actionIdentifier)) {
388
+ return {
389
+ projectRoot: currentRoot,
390
+ packageId: config.id,
391
+ actionId: actionIdentifier,
392
+ };
393
+ }
394
+ } catch {
395
+ // Ignore and proceed to registry lookup
396
+ }
397
+ }
398
+
399
+ // 2. Check if scoped format: <package-id>/<action-id> or <package-id>:<action-id>
400
+ let targetPackage: string | undefined;
401
+ let pureActionId = actionIdentifier;
402
+
403
+ if (actionIdentifier.includes("/")) {
404
+ const slashIdx = actionIdentifier.lastIndexOf("/");
405
+ targetPackage = actionIdentifier.slice(0, slashIdx);
406
+ pureActionId = actionIdentifier.slice(slashIdx + 1);
407
+ } else if (actionIdentifier.includes(":")) {
408
+ const colonIdx = actionIdentifier.lastIndexOf(":");
409
+ targetPackage = actionIdentifier.slice(0, colonIdx);
410
+ pureActionId = actionIdentifier.slice(colonIdx + 1);
411
+ }
412
+
413
+ const linkedList = listLinkedPackages(customHome);
414
+
415
+ if (targetPackage) {
416
+ let targetRoot: string | undefined;
417
+ let targetPkgId = targetPackage;
418
+
419
+ const pkg = linkedList.find(
420
+ (p) => p.id === targetPackage || getPackageSlug(p.id) === targetPackage
421
+ );
422
+ if (pkg && existsSync(pkg.path)) {
423
+ targetRoot = pkg.path;
424
+ targetPkgId = pkg.id;
425
+ } else if (currentRoot) {
426
+ try {
427
+ const config = loadProjectConfig(currentRoot);
428
+ if (config.id === targetPackage || getPackageSlug(config.id) === targetPackage) {
429
+ targetRoot = currentRoot;
430
+ targetPkgId = config.id;
431
+ }
432
+ } catch {
433
+ // Ignore
434
+ }
435
+ }
436
+
437
+ if (!targetRoot || !existsSync(targetRoot)) {
438
+ throw new Error(
439
+ `Linked package '${targetPackage}' not found or path no longer exists (${pkg?.path || "unregistered"}). Run 'ad link' in the package directory.`
440
+ );
441
+ }
442
+
443
+ const config = loadProjectConfig(targetRoot);
444
+ if (!projectHasActionSync(targetRoot, config.actionsDir, pureActionId)) {
445
+ throw new Error(`Action '${pureActionId}' not found in package '${targetPkgId}' (${targetRoot})`);
446
+ }
447
+
448
+ return {
449
+ projectRoot: targetRoot,
450
+ packageId: targetPkgId,
451
+ actionId: pureActionId,
452
+ };
453
+ }
454
+
455
+ // 3. Search across all linked packages
456
+ const matches: Array<{ entry: LinkedPackageEntry; actionId: string }> = [];
457
+
458
+ for (const pkg of linkedList) {
459
+ if (!existsSync(pkg.path)) continue;
460
+ try {
461
+ const config = loadProjectConfig(pkg.path);
462
+ if (projectHasActionSync(pkg.path, config.actionsDir, actionIdentifier)) {
463
+ matches.push({ entry: pkg, actionId: actionIdentifier });
464
+ }
465
+ } catch {
466
+ // Ignore invalid linked package
467
+ }
468
+ }
469
+
470
+ if (matches.length === 1) {
471
+ return {
472
+ projectRoot: matches[0].entry.path,
473
+ packageId: matches[0].entry.id,
474
+ actionId: matches[0].actionId,
475
+ };
476
+ }
477
+
478
+ if (matches.length > 1) {
479
+ const pkgList = matches.map((m) => `'${m.entry.id}'`).join(", ");
480
+ throw new Error(
481
+ `Action '${actionIdentifier}' is provided by multiple linked packages: ${pkgList}. Please specify using '<package-id>/${actionIdentifier}'.`
482
+ );
483
+ }
484
+
485
+ if (currentRoot) {
486
+ throw new Error(`Action '${actionIdentifier}' not found in current project or any linked packages`);
487
+ } else {
488
+ throw new Error(
489
+ `Action '${actionIdentifier}' not found. You are not in an ActionDock project, and no linked package provides '${actionIdentifier}'. Use 'ad link' to register your package.`
490
+ );
491
+ }
492
+ }
493
+
333
494
  export async function resolveActionProject(
334
495
  actionIdentifier: string,
335
496
  cwd: string = process.cwd(),
@@ -340,8 +501,7 @@ export async function resolveActionProject(
340
501
  if (currentRoot) {
341
502
  try {
342
503
  const config = loadProjectConfig(currentRoot);
343
- const actions = await loadActions(currentRoot, config.actionsDir);
344
- if (actions.has(actionIdentifier)) {
504
+ if (await projectHasAction(currentRoot, config.actionsDir, actionIdentifier)) {
345
505
  return {
346
506
  projectRoot: currentRoot,
347
507
  packageId: config.id,
@@ -358,11 +518,11 @@ export async function resolveActionProject(
358
518
  let pureActionId = actionIdentifier;
359
519
 
360
520
  if (actionIdentifier.includes("/")) {
361
- const slashIdx = actionIdentifier.indexOf("/");
521
+ const slashIdx = actionIdentifier.lastIndexOf("/");
362
522
  targetPackage = actionIdentifier.slice(0, slashIdx);
363
523
  pureActionId = actionIdentifier.slice(slashIdx + 1);
364
524
  } else if (actionIdentifier.includes(":")) {
365
- const colonIdx = actionIdentifier.indexOf(":");
525
+ const colonIdx = actionIdentifier.lastIndexOf(":");
366
526
  targetPackage = actionIdentifier.slice(0, colonIdx);
367
527
  pureActionId = actionIdentifier.slice(colonIdx + 1);
368
528
  }
@@ -370,25 +530,41 @@ export async function resolveActionProject(
370
530
  const linkedList = listLinkedPackages(customHome);
371
531
 
372
532
  if (targetPackage) {
533
+ let targetRoot: string | undefined;
534
+ let targetPkgId = targetPackage;
535
+
373
536
  const pkg = linkedList.find(
374
537
  (p) => p.id === targetPackage || getPackageSlug(p.id) === targetPackage
375
538
  );
539
+ if (pkg && existsSync(pkg.path)) {
540
+ targetRoot = pkg.path;
541
+ targetPkgId = pkg.id;
542
+ } else if (currentRoot) {
543
+ try {
544
+ const config = loadProjectConfig(currentRoot);
545
+ if (config.id === targetPackage || getPackageSlug(config.id) === targetPackage) {
546
+ targetRoot = currentRoot;
547
+ targetPkgId = config.id;
548
+ }
549
+ } catch {
550
+ // Ignore
551
+ }
552
+ }
376
553
 
377
- if (!pkg || !existsSync(pkg.path)) {
554
+ if (!targetRoot || !existsSync(targetRoot)) {
378
555
  throw new Error(
379
556
  `Linked package '${targetPackage}' not found or path no longer exists (${pkg?.path || "unregistered"}). Run 'ad link' in the package directory.`
380
557
  );
381
558
  }
382
559
 
383
- const config = loadProjectConfig(pkg.path);
384
- const actions = await loadActions(pkg.path, config.actionsDir);
385
- if (!actions.has(pureActionId)) {
386
- throw new Error(`Action '${pureActionId}' not found in package '${pkg.id}' (${pkg.path})`);
560
+ const config = loadProjectConfig(targetRoot);
561
+ if (!(await projectHasAction(targetRoot, config.actionsDir, pureActionId))) {
562
+ throw new Error(`Action '${pureActionId}' not found in package '${targetPkgId}' (${targetRoot})`);
387
563
  }
388
564
 
389
565
  return {
390
- projectRoot: pkg.path,
391
- packageId: pkg.id,
566
+ projectRoot: targetRoot,
567
+ packageId: targetPkgId,
392
568
  actionId: pureActionId,
393
569
  };
394
570
  }
@@ -400,8 +576,7 @@ export async function resolveActionProject(
400
576
  if (!existsSync(pkg.path)) continue;
401
577
  try {
402
578
  const config = loadProjectConfig(pkg.path);
403
- const actions = await loadActions(pkg.path, config.actionsDir);
404
- if (actions.has(actionIdentifier)) {
579
+ if (await projectHasAction(pkg.path, config.actionsDir, actionIdentifier)) {
405
580
  matches.push({ entry: pkg, actionId: actionIdentifier });
406
581
  }
407
582
  } catch {
@@ -438,24 +613,69 @@ export function resolvePackageRoot(
438
613
  cwd?: string,
439
614
  customHome?: string
440
615
  ): string | null {
441
- if (packageIdOrPath) {
442
- const directRoot = findProjectRoot(packageIdOrPath);
443
- if (directRoot) return directRoot;
444
-
445
- const linkedList = listLinkedPackages(customHome);
446
- const found = linkedList.find(
447
- (p) =>
448
- p.id === packageIdOrPath ||
449
- getPackageSlug(p.id) === packageIdOrPath ||
450
- p.path === resolve(packageIdOrPath)
451
- );
452
- if (found) {
453
- return found.path;
616
+ if (!packageIdOrPath) {
617
+ return findProjectRoot(cwd);
618
+ }
619
+
620
+ const baseDir = cwd || process.cwd();
621
+ const resolvedPath = resolve(baseDir, packageIdOrPath);
622
+
623
+ // 1. Check if packageIdOrPath is an existing directory or file path on disk
624
+ if (existsSync(resolvedPath)) {
625
+ try {
626
+ const stat = statSync(resolvedPath);
627
+ const targetDir = stat.isDirectory() ? resolvedPath : dirname(resolvedPath);
628
+ if (existsSync(join(targetDir, "actiondock.json"))) {
629
+ return targetDir;
630
+ }
631
+ const parentRoot = findProjectRoot(targetDir);
632
+ if (parentRoot) {
633
+ return parentRoot;
634
+ }
635
+ } catch {
636
+ // ignore
454
637
  }
638
+ }
639
+
640
+ // If it was explicitly a path (starts with . or / or ~ or contains / or \), and did not resolve above:
641
+ const isExplicitPath =
642
+ packageIdOrPath.startsWith(".") ||
643
+ packageIdOrPath.startsWith("/") ||
644
+ packageIdOrPath.startsWith("~") ||
645
+ packageIdOrPath.includes("/") ||
646
+ packageIdOrPath.includes("\\");
647
+
648
+ if (isExplicitPath) {
649
+ // An explicit path that does not exist or is not an ActionDock project must fail
455
650
  return null;
456
651
  }
457
652
 
458
- return findProjectRoot(cwd);
653
+ // 2. Check linked packages in registry
654
+ const linkedList = listLinkedPackages(customHome);
655
+ const found = linkedList.find(
656
+ (p) =>
657
+ p.id === packageIdOrPath ||
658
+ getPackageSlug(p.id) === packageIdOrPath ||
659
+ p.path === resolvedPath
660
+ );
661
+ if (found) {
662
+ return found.path;
663
+ }
664
+
665
+ // 3. Check current project (from cwd)
666
+ const currentRoot = findProjectRoot(cwd);
667
+ if (currentRoot) {
668
+ try {
669
+ const config = loadProjectConfig(currentRoot);
670
+ if (config.id === packageIdOrPath || getPackageSlug(config.id) === packageIdOrPath) {
671
+ return currentRoot;
672
+ }
673
+ } catch {
674
+ // ignore broken config
675
+ }
676
+ }
677
+
678
+ return null;
459
679
  }
460
680
 
461
681
  export function resolvePlaybookProject(
@@ -499,26 +719,43 @@ export function resolvePlaybookProject(
499
719
  const linkedList = listLinkedPackages(customHome);
500
720
 
501
721
  if (targetPackage) {
722
+ let targetRoot: string | undefined;
723
+ let targetPkgId = targetPackage;
724
+
502
725
  const pkg = linkedList.find(
503
726
  (p) => p.id === targetPackage || getPackageSlug(p.id) === targetPackage
504
727
  );
728
+ if (pkg && existsSync(pkg.path)) {
729
+ targetRoot = pkg.path;
730
+ targetPkgId = pkg.id;
731
+ } else if (currentRoot) {
732
+ try {
733
+ const config = loadProjectConfig(currentRoot);
734
+ if (config.id === targetPackage || getPackageSlug(config.id) === targetPackage) {
735
+ targetRoot = currentRoot;
736
+ targetPkgId = config.id;
737
+ }
738
+ } catch {
739
+ // Ignore
740
+ }
741
+ }
505
742
 
506
- if (!pkg || !existsSync(pkg.path)) {
743
+ if (!targetRoot || !existsSync(targetRoot)) {
507
744
  throw new Error(
508
745
  `Linked package '${targetPackage}' not found or path no longer exists (${pkg?.path || "unregistered"}). Run 'ad link' in the package directory.`
509
746
  );
510
747
  }
511
748
 
512
- const config = loadProjectConfig(pkg.path);
513
- const playbooks = loadPlaybooks(pkg.path, config.playbooksDir);
749
+ const config = loadProjectConfig(targetRoot);
750
+ const playbooks = loadPlaybooks(targetRoot, config.playbooksDir);
514
751
  const pb = playbooks.get(purePlaybookId);
515
752
  if (!pb) {
516
- throw new Error(`Playbook '${purePlaybookId}' not found in package '${pkg.id}' (${pkg.path})`);
753
+ throw new Error(`Playbook '${purePlaybookId}' not found in package '${targetPkgId}' (${targetRoot})`);
517
754
  }
518
755
 
519
756
  return {
520
- projectRoot: pkg.path,
521
- packageId: pkg.id,
757
+ projectRoot: targetRoot,
758
+ packageId: targetPkgId,
522
759
  playbookId: purePlaybookId,
523
760
  playbook: pb,
524
761
  };
@@ -3,6 +3,7 @@ import type {
3
3
  ActionContext,
4
4
  ActionDefinition,
5
5
  ActionInvoker,
6
+ ActionRef,
6
7
  Config,
7
8
  Logger,
8
9
  ProcessAPI,
@@ -194,8 +195,9 @@ export interface ContextOptions {
194
195
  signal?: AbortSignal;
195
196
  process?: ProcessAPI;
196
197
  progress?: ProgressReporter;
198
+ logger?: Logger;
197
199
  onActionInvoke?: (
198
- action: ActionDefinition,
200
+ action: ActionDefinition | ActionRef | string,
199
201
  input: unknown,
200
202
  parentRunId?: string
201
203
  ) => Promise<unknown>;
@@ -214,13 +216,16 @@ export function createActionContext(options: ContextOptions): ActionContext {
214
216
  options.projectConfig
215
217
  );
216
218
  const state = new RuntimeStateStore(options.storage);
217
- const log = new StderrLogger();
219
+ const log = options.logger || new StderrLogger();
218
220
  const signal = options.signal ?? new AbortController().signal;
219
221
  const currentRunId = options.runId || randomUUID();
220
222
  const currentRootRunId = options.rootRunId || options.parentRunId || currentRunId;
221
223
 
222
224
  const invoker: ActionInvoker = {
223
- async invoke<I, O>(action: ActionDefinition<I, O>, input: I): Promise<O> {
225
+ async invoke<I, O>(
226
+ action: ActionDefinition<I, O> | ActionRef | string,
227
+ input?: I
228
+ ): Promise<O> {
224
229
  if (options.onActionInvoke) {
225
230
  return (await options.onActionInvoke(
226
231
  action as any,