easy-coding-harness 0.5.0 → 0.5.1

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.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,12 @@
6
6
  - `y`:常规功能升级
7
7
  - `z`:日常 bug 修复
8
8
 
9
+ ## 0.5.1
10
+
11
+ - 修复 Claude Code hook 命令使用相对路径导致在子目录 cwd 下找不到 `.claude/hooks/*.py` 的问题;Claude、Codex、Qoder 的 hook 配置现在安装时渲染为带引号的绝对脚本路径。
12
+ - `easy-coding upgrade` 可直接刷新存量 0.5.0 项目的 hook 配置并解决该问题;升级后 `ec-init` 合规扫描会校验 hook 配置是否已使用当前版本要求的绝对路径。
13
+ - `easy-coding clear` 和 `install-manifest.json` 兼容新绝对 hook 命令,清理时仍按项目相对 hook 路径识别托管注册项。
14
+
9
15
  ## 0.5.0
10
16
 
11
17
  - 新增 supermodule 支持:`easy-coding init` 会识别 `.gitmodules`,父仓必装,已检出的一级子仓可按交互或参数选择分层安装。
package/dist/cli.js CHANGED
@@ -350,7 +350,7 @@ async function writeInstallManifest(cwd, params) {
350
350
  const registration = {
351
351
  config_path: configPath2,
352
352
  command: artifact.command,
353
- hook_path: artifact.hookPath,
353
+ hook_path: artifact.hookPath ? normalizeHookPath(cwd, artifact.hookPath) : null,
354
354
  platform: artifact.platform
355
355
  };
356
356
  hookRegistrations.set(`${configPath2}\0${normalizeCommand(artifact.command)}`, registration);
@@ -465,9 +465,75 @@ function collectHookCommands(hooks) {
465
465
  return commands;
466
466
  }
467
467
  function extractHookPathFromCommand(command) {
468
- const match = normalizeCommand(command).match(/(?:^|\s)(\S+\/hooks\/\S+\.py)(?:\s|$)/);
468
+ return extractQuotedHookPath(command) ?? extractUnquotedHookPath(command);
469
+ }
470
+ function extractQuotedHookPath(command) {
471
+ for (let index = 0; index < command.length; index += 1) {
472
+ const quote = command[index];
473
+ if (quote !== '"' && quote !== "'") {
474
+ continue;
475
+ }
476
+ if (index > 0 && !/\s/.test(command[index - 1])) {
477
+ continue;
478
+ }
479
+ const parsed = parseQuotedToken(command, index, quote);
480
+ if (!parsed) {
481
+ continue;
482
+ }
483
+ const suffix = readPathSuffix(command, parsed.endIndex);
484
+ for (const candidate of [parsed.value + suffix, parsed.value]) {
485
+ if (isHookPythonPath(candidate)) {
486
+ return candidate;
487
+ }
488
+ }
489
+ index = parsed.endIndex;
490
+ }
491
+ return null;
492
+ }
493
+ function parseQuotedToken(command, startIndex, quote) {
494
+ let value = "";
495
+ for (let index = startIndex + 1; index < command.length; index += 1) {
496
+ const char = command[index];
497
+ if (quote === '"' && char === "\\" && index + 1 < command.length) {
498
+ value += command[index + 1];
499
+ index += 1;
500
+ continue;
501
+ }
502
+ if (char === quote) {
503
+ return { value, endIndex: index + 1 };
504
+ }
505
+ value += char;
506
+ }
507
+ return null;
508
+ }
509
+ function readPathSuffix(command, startIndex) {
510
+ if (command[startIndex] !== "/") {
511
+ return "";
512
+ }
513
+ let suffix = "";
514
+ for (let index = startIndex; index < command.length; index += 1) {
515
+ const char = command[index];
516
+ if (/\s/.test(char)) {
517
+ break;
518
+ }
519
+ suffix += char;
520
+ }
521
+ return suffix;
522
+ }
523
+ function extractUnquotedHookPath(command) {
524
+ const match = command.match(/(?:^|\s)(\S+\/hooks\/\S+\.py)(?:\s|$)/);
469
525
  return match?.[1] ?? null;
470
526
  }
527
+ function isHookPythonPath(candidate) {
528
+ return /\/hooks\/\S+\.py$/.test(candidate);
529
+ }
530
+ function normalizeHookPath(cwd, hookPath) {
531
+ const normalized = hookPath.replace(/\\/g, "/").replace(/^\.\//, "");
532
+ if (path4.isAbsolute(hookPath) || path4.posix.isAbsolute(normalized) || path4.win32.isAbsolute(hookPath)) {
533
+ return toProjectPath(cwd, hookPath);
534
+ }
535
+ return normalized;
536
+ }
471
537
  function byPath(a, b) {
472
538
  return a.path.localeCompare(b.path);
473
539
  }
@@ -696,6 +762,33 @@ function resolvePlaceholders(content, ctx, contentName = "template") {
696
762
  }
697
763
  return resolved;
698
764
  }
765
+ function withInstallPaths(cwd, ctx) {
766
+ const hooksDir = toPosixPath(path8.resolve(cwd, ctx.platform_config_dir, "hooks"));
767
+ return {
768
+ ...ctx,
769
+ platform_hooks_dir_abs: hooksDir,
770
+ platform_hooks_dir_shell: jsonStringContent(shellDoubleQuoteArg(hooksDir))
771
+ };
772
+ }
773
+ function renderHookCommand(cwd, ctx, scriptName, platform = process.platform) {
774
+ const hooksDir = toPosixPath(path8.resolve(cwd, ctx.platform_config_dir, "hooks"));
775
+ return `${ctx.python_cmd} ${shellDoubleQuoteArg(hooksDir, platform)}/${scriptName}`;
776
+ }
777
+ function shellDoubleQuoteArg(value, platform = process.platform) {
778
+ if (platform === "win32") {
779
+ if (value.includes('"')) {
780
+ throw new Error("Windows hook paths cannot contain double quotes.");
781
+ }
782
+ return `"${value}"`;
783
+ }
784
+ return `"${value.replace(/(["\\$`])/g, "\\$1")}"`;
785
+ }
786
+ function toPosixPath(filePath) {
787
+ return filePath.split(path8.sep).join("/");
788
+ }
789
+ function jsonStringContent(value) {
790
+ return JSON.stringify(value).slice(1, -1);
791
+ }
699
792
  async function resolveSkills(ctx) {
700
793
  const skillsRoot = getTemplatePath("common", "skills");
701
794
  const entries = await readdir2(skillsRoot);
@@ -851,7 +944,7 @@ function stripTemplateExtension(fileName) {
851
944
  async function configureClaude(cwd, opts = {}) {
852
945
  const platform = "claude-code";
853
946
  const meta = PLATFORM_META[platform];
854
- const ctx = meta.templateContext;
947
+ const ctx = withInstallPaths(cwd, meta.templateContext);
855
948
  const dest = path9.join(cwd, ".claude");
856
949
  const hookConfigPath = path9.join(cwd, meta.hookConfigFile);
857
950
  const artifacts = [];
@@ -889,7 +982,7 @@ import path10 from "path";
889
982
  async function configureCodex(cwd, opts = {}) {
890
983
  const platform = "codex";
891
984
  const meta = PLATFORM_META[platform];
892
- const ctx = meta.templateContext;
985
+ const ctx = withInstallPaths(cwd, meta.templateContext);
893
986
  const hookConfigPath = path10.join(cwd, meta.hookConfigFile);
894
987
  const artifacts = [];
895
988
  artifacts.push(
@@ -1005,7 +1098,7 @@ async function existingManagedSkillArtifacts(skillsDir, skillNames, platform) {
1005
1098
  async function configureQoder(cwd, opts = {}) {
1006
1099
  const platform = "qoder";
1007
1100
  const meta = resolvePlatformMeta(cwd, platform);
1008
- const ctx = meta.templateContext;
1101
+ const ctx = withInstallPaths(cwd, meta.templateContext);
1009
1102
  const dest = path12.join(cwd, ctx.platform_config_dir);
1010
1103
  const hookConfigPath = path12.join(cwd, meta.hookConfigFile);
1011
1104
  const artifacts = [];
@@ -1770,11 +1863,12 @@ async function buildManifestClearPlan(cwd, agents, manifest) {
1770
1863
  if (!agentSet.has(registration.platform)) {
1771
1864
  continue;
1772
1865
  }
1866
+ const managedHookPaths = registration.hook_path ? managedHookPathsForRegistration(cwd, registration.hook_path) : [];
1773
1867
  addHookConfigPrune(
1774
1868
  plan.pruneHookConfigs,
1775
1869
  plan.pruneHookCommands,
1776
1870
  manifestPath(cwd, registration.config_path),
1777
- registration.hook_path ? [registration.hook_path] : [],
1871
+ managedHookPaths,
1778
1872
  [registration.command]
1779
1873
  );
1780
1874
  }
@@ -1822,7 +1916,7 @@ async function addTemplateClearEntries(plan, cwd, platform, metas, managedSkills
1822
1916
  plan.pruneHookConfigs,
1823
1917
  plan.pruneHookCommands,
1824
1918
  path16.join(cwd, meta.hookConfigFile),
1825
- hookFileNames.map((name) => `${meta.templateContext.platform_config_dir}/hooks/${name}`),
1919
+ managedHookPathsForTemplate(cwd, meta, hookFileNames),
1826
1920
  []
1827
1921
  );
1828
1922
  plan.constraints.add(path16.join(cwd, meta.mainConstraint));
@@ -1873,6 +1967,51 @@ function addHookConfigPrune(pruneHookConfigs, pruneHookCommands, filePath, manag
1873
1967
  }
1874
1968
  pruneHookCommands.set(filePath, existingCommands);
1875
1969
  }
1970
+ function managedHookPathsForRegistration(cwd, hookPath) {
1971
+ const paths = [hookPath];
1972
+ try {
1973
+ paths.push(...managedHookPathTokens(manifestPath(cwd, hookPath)));
1974
+ } catch {
1975
+ if (path16.isAbsolute(hookPath)) {
1976
+ paths.push(...managedHookPathTokens(hookPath));
1977
+ }
1978
+ }
1979
+ return paths;
1980
+ }
1981
+ function managedHookPathsForTemplate(cwd, meta, hookFileNames) {
1982
+ const relativePaths = hookFileNames.map(
1983
+ (name) => `${meta.templateContext.platform_config_dir}/hooks/${name}`
1984
+ );
1985
+ const absolutePaths = hookFileNames.flatMap(
1986
+ (name) => managedHookPathTokens(path16.join(cwd, meta.hooksDir, name))
1987
+ );
1988
+ return [...relativePaths, ...absolutePaths];
1989
+ }
1990
+ function managedHookPathTokens(filePath) {
1991
+ const tokens = /* @__PURE__ */ new Set();
1992
+ for (const equivalentPath of equivalentAbsolutePaths(filePath)) {
1993
+ const normalized = equivalentPath.replace(/\\/g, "/");
1994
+ const dir = path16.posix.dirname(normalized);
1995
+ const basename = path16.posix.basename(normalized);
1996
+ tokens.add(normalized);
1997
+ tokens.add(shellDoubleQuoteArg2(normalized));
1998
+ tokens.add(`${shellDoubleQuoteArg2(dir)}/${basename}`);
1999
+ }
2000
+ return [...tokens];
2001
+ }
2002
+ function equivalentAbsolutePaths(filePath) {
2003
+ const normalized = path16.resolve(filePath).replace(/\\/g, "/");
2004
+ const equivalents = [normalized];
2005
+ if (normalized.startsWith("/private/var/")) {
2006
+ equivalents.push(normalized.replace(/^\/private\/var\//, "/var/"));
2007
+ } else if (normalized.startsWith("/var/")) {
2008
+ equivalents.push(normalized.replace(/^\/var\//, "/private/var/"));
2009
+ }
2010
+ return equivalents;
2011
+ }
2012
+ function shellDoubleQuoteArg2(value) {
2013
+ return `"${value.replace(/(["\\$`])/g, "\\$1")}"`;
2014
+ }
1876
2015
  function createClearPlan() {
1877
2016
  return {
1878
2017
  remove: /* @__PURE__ */ new Set(),
@@ -2057,7 +2196,7 @@ function isManagedHook(hook, managedHookPaths, managedHookCommands) {
2057
2196
  if (normalizedManagedCommands.includes(normalizeCommand(command))) {
2058
2197
  return true;
2059
2198
  }
2060
- const normalizedCommand = command.replace(/\\/g, "/");
2199
+ const normalizedCommand = command;
2061
2200
  return managedHookPaths.some(
2062
2201
  (hookPath) => commandContainsPathToken(normalizedCommand, hookPath.replace(/\\/g, "/"))
2063
2202
  );
@@ -2543,8 +2682,35 @@ async function update(opts) {
2543
2682
  }
2544
2683
 
2545
2684
  // src/commands/upgrade.ts
2685
+ import path22 from "path";
2546
2686
  import { cancel as cancel5, confirm as confirm4, outro as outro5 } from "@clack/prompts";
2547
2687
  import chalk7 from "chalk";
2688
+ var EXPECTED_HOOK_REGISTRATION_SCRIPTS = {
2689
+ "claude-code": [
2690
+ { event: "SessionStart", scriptName: "session-start.py" },
2691
+ { event: "UserPromptSubmit", scriptName: "session-start.py" },
2692
+ { event: "UserPromptSubmit", scriptName: "inject-workflow-state.py" },
2693
+ { event: "PreToolUse", scriptName: "inject-subagent-context.py" }
2694
+ ],
2695
+ codex: [
2696
+ { event: "UserPromptSubmit", scriptName: "session-start.py" },
2697
+ { event: "UserPromptSubmit", scriptName: "inject-workflow-state.py" }
2698
+ ],
2699
+ qoder: [
2700
+ { event: "UserPromptSubmit", scriptName: "session-start.py" },
2701
+ { event: "UserPromptSubmit", scriptName: "inject-workflow-state.py" },
2702
+ { event: "PreToolUse", scriptName: "inject-subagent-context.py" }
2703
+ ]
2704
+ };
2705
+ var MANAGED_HOOK_SCRIPT_NAMES = {
2706
+ "claude-code": [
2707
+ ...new Set(
2708
+ EXPECTED_HOOK_REGISTRATION_SCRIPTS["claude-code"].map(({ scriptName }) => scriptName)
2709
+ )
2710
+ ],
2711
+ codex: [...new Set(EXPECTED_HOOK_REGISTRATION_SCRIPTS.codex.map(({ scriptName }) => scriptName))],
2712
+ qoder: [...new Set(EXPECTED_HOOK_REGISTRATION_SCRIPTS.qoder.map(({ scriptName }) => scriptName))]
2713
+ };
2548
2714
  async function upgrade(opts) {
2549
2715
  renderBanner();
2550
2716
  const cwd = process.cwd();
@@ -2645,12 +2811,149 @@ async function resolvePendingUpgradeTargets(targets) {
2645
2811
  `${target.label} harness version ${installedVersion} is newer than CLI ${VERSION}. Update the CLI first.`
2646
2812
  );
2647
2813
  }
2648
- if (relation === -1) {
2814
+ if (relation === -1 || relation === 0 && await needsHookConfigRefresh(target, config.agents)) {
2649
2815
  pending.push({ target, config });
2650
2816
  }
2651
2817
  }
2652
2818
  return pending;
2653
2819
  }
2820
+ async function needsHookConfigRefresh(target, agents) {
2821
+ const manifest = await readInstallManifest(target.dir);
2822
+ for (const agent of agents) {
2823
+ const meta = resolvePlatformMeta(target.dir, agent);
2824
+ const configPath2 = path22.join(target.dir, meta.hookConfigFile);
2825
+ const content = await readTextIfExists(configPath2);
2826
+ if (content === null) {
2827
+ return true;
2828
+ }
2829
+ let parsed;
2830
+ try {
2831
+ parsed = JSON.parse(content);
2832
+ } catch {
2833
+ return true;
2834
+ }
2835
+ const commandsByEvent = collectHookCommandsByEvent(parsed.hooks);
2836
+ const commands = [...commandsByEvent.values()].flat();
2837
+ const expectedRegistrations = expectedHookRegistrations(target.dir, meta, agent);
2838
+ const expectedCommandSet = new Set(
2839
+ expectedRegistrations.map((registration) => registration.command)
2840
+ );
2841
+ if (!hasExpectedHookRegistrations(commandsByEvent, expectedRegistrations)) {
2842
+ return true;
2843
+ }
2844
+ const manifestManagedCommands = new Set(
2845
+ manifest?.hook_registrations.filter((registration) => registration.platform === agent).map((registration) => registration.command) ?? []
2846
+ );
2847
+ if (commands.some(
2848
+ (command) => isUnexpectedManagedHookCommand(
2849
+ command,
2850
+ expectedCommandSet,
2851
+ target.dir,
2852
+ meta,
2853
+ agent,
2854
+ manifestManagedCommands
2855
+ )
2856
+ )) {
2857
+ return true;
2858
+ }
2859
+ }
2860
+ return false;
2861
+ }
2862
+ function expectedHookRegistrations(cwd, meta, platform) {
2863
+ return EXPECTED_HOOK_REGISTRATION_SCRIPTS[platform].map(({ event, scriptName }) => ({
2864
+ event,
2865
+ command: renderHookCommand(cwd, meta.templateContext, scriptName)
2866
+ }));
2867
+ }
2868
+ function hasExpectedHookRegistrations(commandsByEvent, expectedRegistrations) {
2869
+ const actualCounts = countRegistrations(
2870
+ [...commandsByEvent.entries()].flatMap(
2871
+ ([event, commands]) => commands.map((command) => ({ event, command }))
2872
+ )
2873
+ );
2874
+ const expectedCounts = countRegistrations(expectedRegistrations);
2875
+ return [...expectedCounts.entries()].every(
2876
+ ([registrationKey, expectedCount]) => (actualCounts.get(registrationKey) ?? 0) >= expectedCount
2877
+ );
2878
+ }
2879
+ function countRegistrations(registrations) {
2880
+ const counts = /* @__PURE__ */ new Map();
2881
+ for (const registration of registrations) {
2882
+ const key = hookRegistrationKey(registration);
2883
+ counts.set(key, (counts.get(key) ?? 0) + 1);
2884
+ }
2885
+ return counts;
2886
+ }
2887
+ function hookRegistrationKey(registration) {
2888
+ return `${registration.event}\0${registration.command}`;
2889
+ }
2890
+ function collectHookCommandsByEvent(hooks) {
2891
+ if (!hooks || typeof hooks !== "object" || Array.isArray(hooks)) {
2892
+ return /* @__PURE__ */ new Map();
2893
+ }
2894
+ const commandsByEvent = /* @__PURE__ */ new Map();
2895
+ for (const [event, value] of Object.entries(hooks)) {
2896
+ if (!Array.isArray(value)) {
2897
+ continue;
2898
+ }
2899
+ for (const group of value) {
2900
+ if (!group || typeof group !== "object") {
2901
+ continue;
2902
+ }
2903
+ const hookItems = group.hooks;
2904
+ if (!Array.isArray(hookItems)) {
2905
+ continue;
2906
+ }
2907
+ for (const hook of hookItems) {
2908
+ if (!hook || typeof hook !== "object") {
2909
+ continue;
2910
+ }
2911
+ const command = hook.command;
2912
+ if (typeof command === "string") {
2913
+ const eventCommands = commandsByEvent.get(event) ?? [];
2914
+ eventCommands.push(command);
2915
+ commandsByEvent.set(event, eventCommands);
2916
+ }
2917
+ }
2918
+ }
2919
+ }
2920
+ return commandsByEvent;
2921
+ }
2922
+ function isUnexpectedManagedHookCommand(command, expectedCommands, cwd, meta, platform, manifestManagedCommands) {
2923
+ if (expectedCommands.has(command)) {
2924
+ return false;
2925
+ }
2926
+ if (manifestManagedCommands.has(command)) {
2927
+ return true;
2928
+ }
2929
+ const hookPath = extractHookPathFromCommand(command);
2930
+ return hookPath === null ? false : isCurrentProjectManagedHookPath(cwd, hookPath, meta, platform);
2931
+ }
2932
+ function isCurrentProjectManagedHookPath(cwd, hookPath, meta, platform) {
2933
+ const normalizedHookPath = normalizePathForHookComparison(hookPath);
2934
+ const configDir = normalizePathForHookComparison(meta.templateContext.platform_config_dir);
2935
+ return MANAGED_HOOK_SCRIPT_NAMES[platform].some((scriptName) => {
2936
+ const relativeHookPath = `${configDir}/hooks/${scriptName}`;
2937
+ if (normalizedHookPath === relativeHookPath) {
2938
+ return true;
2939
+ }
2940
+ return pathAliases(
2941
+ normalizePathForHookComparison(path22.resolve(cwd, relativeHookPath))
2942
+ ).includes(normalizedHookPath);
2943
+ });
2944
+ }
2945
+ function normalizePathForHookComparison(value) {
2946
+ return value.replace(/\\/g, "/").replace(/^\.\//, "");
2947
+ }
2948
+ function pathAliases(value) {
2949
+ const aliases = /* @__PURE__ */ new Set([value]);
2950
+ if (value.startsWith("/private/var/")) {
2951
+ aliases.add(value.replace(/^\/private\/var\//, "/var/"));
2952
+ } else if (value.startsWith("/var/")) {
2953
+ aliases.add(`/private${value}`);
2954
+ }
2955
+ return [...aliases];
2956
+ }
2654
2957
  async function resolveParentTopologyRefresh(targets, pending) {
2655
2958
  const parent = targets.find(
2656
2959
  (target) => target.label === "." && target.supermodule.role === "super-parent"