@c4a/context-cli 0.6.0-beta.6 → 0.6.0-beta.7

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 (29) hide show
  1. package/README.md +7 -2
  2. package/cli.js +2305 -1570
  3. package/package.json +2 -2
  4. package/plugin/skills/skill-continue-workflow/SKILL.md +4 -0
  5. package/plugin/skills/skill-prose-align/SKILL.md +7 -6
  6. package/plugin/skills/skill-prose-compile/SKILL.md +10 -8
  7. package/plugins/VERSION +1 -1
  8. package/plugins/claude/.claude-plugin/plugin.json +1 -1
  9. package/plugins/claude/skills/skill-continue-workflow/SKILL.md +4 -0
  10. package/plugins/claude/skills/skill-prose-align/SKILL.md +7 -6
  11. package/plugins/claude/skills/skill-prose-compile/SKILL.md +10 -8
  12. package/plugins/codex/.codex-plugin/plugin.json +2 -2
  13. package/plugins/codex/skills/continue/references/internal-procedures/skill-continue-workflow.md +4 -0
  14. package/plugins/codex/skills/continue/references/internal-procedures/skill-prose-align.md +7 -6
  15. package/plugins/codex/skills/continue/references/internal-procedures/skill-prose-compile.md +10 -8
  16. package/plugins/codex/skills/init/references/internal-procedures/skill-continue-workflow.md +4 -0
  17. package/plugins/codex/skills/init/references/internal-procedures/skill-prose-align.md +7 -6
  18. package/plugins/codex/skills/init/references/internal-procedures/skill-prose-compile.md +10 -8
  19. package/plugins/cursor/.cursor-plugin/plugin.json +1 -1
  20. package/plugins/cursor/skills/skill-continue-workflow/SKILL.md +4 -0
  21. package/plugins/cursor/skills/skill-prose-align/SKILL.md +7 -6
  22. package/plugins/cursor/skills/skill-prose-compile/SKILL.md +10 -8
  23. package/plugins/skills/context-continue/references/internal-procedures/skill-continue-workflow.md +4 -0
  24. package/plugins/skills/context-continue/references/internal-procedures/skill-prose-align.md +7 -6
  25. package/plugins/skills/context-continue/references/internal-procedures/skill-prose-compile.md +10 -8
  26. package/plugins/skills/context-init/references/internal-procedures/skill-continue-workflow.md +4 -0
  27. package/plugins/skills/context-init/references/internal-procedures/skill-prose-align.md +7 -6
  28. package/plugins/skills/context-init/references/internal-procedures/skill-prose-compile.md +10 -8
  29. package/scripts/postinstall.mjs +68 -129
@@ -1,106 +1,50 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * postinstall hook for @c4a/context-cli.
4
- *
5
- * The goal is narrow: when someone installs this package as a Claude Code
6
- * plugin, the `context` bin should end up on PATH so `/context:*` slash
7
- * commands can Bash out to it. In every other install scenario (library
8
- * dependency, `bun install -g`, CI, local `bun link`) we do nothing.
9
- *
10
- * Dangerous things this script used to do (review findings 高-1 / 高-2):
11
- * - Recursion: the child `bun install -g @c4a/context-cli` inherits the
12
- * same Claude env vars that triggered us, runs postinstall again, and
13
- * loops. Fixed by (a) hard re-entry guard via CONTEXT_CLI_POSTINSTALL
14
- * in the child env, and (b) skipping entirely when we are already
15
- * inside a global install (`npm_config_global === "true"`).
16
- * - Dev mode break: pinning the registry spec `@c4a/context-cli@<version>`
17
- * ignores the actual install source (local tarball, file:, github:,
18
- * unpublished pre-release). Fixed by never spawning a child install;
19
- * we print a single actionable hint with the registry fallback command.
20
- * The plugin tarball already contains the whole CLI, so the user can
21
- * just `bun install -g <same source>` themselves — we can't guess the
22
- * source reliably from inside postinstall.
23
- *
24
- * Structure: all decision logic lives in the pure, exported `decide()`
25
- * function. The top-level side-effect wrapper is the only thing that reads
26
- * `process.env`, spawns the PATH probe, or writes to stderr. This split
27
- * lets a sibling `.test.ts` exercise every branch without spawning
28
- * subprocesses (Bun test's subprocess sandboxing has been unreliable in
29
- * this package, so in-process tests are the practical way to cover the
30
- * script).
3
+ * Best-effort global agent plugin refresh after a global context-cli install.
4
+ * Local project dependencies and CI installs must not mutate user-level agent
5
+ * configuration. Link development performs the same refresh in cliLink.ts.
31
6
  */
32
7
 
33
8
  import { spawnSync } from "node:child_process";
34
- import { realpathSync } from "node:fs";
9
+ import { existsSync, realpathSync } from "node:fs";
35
10
  import process from "node:process";
36
- import { pathToFileURL } from "node:url";
11
+ import { dirname, join } from "node:path";
12
+ import { fileURLToPath, pathToFileURL } from "node:url";
37
13
 
38
14
  export const RE_ENTRY_ENV = "CONTEXT_CLI_POSTINSTALL";
15
+ export const SKIP_PLUGIN_INSTALL_ENV = "CONTEXT_CLI_SKIP_PLUGIN_INSTALL";
16
+ export const FORCE_PLUGIN_INSTALL_ENV = "CONTEXT_CLI_AUTO_PLUGIN_INSTALL";
17
+
18
+ export function looksLikeClaudePluginInstall(env) {
19
+ const hints = ["CLAUDE_PLUGIN_INSTALL", "CLAUDE_CODE_PLUGIN", "npm_config_user_agent"];
20
+ return hints.some((key) => env[key]?.toLowerCase().includes("claude") === true);
21
+ }
22
+
23
+ export function isGlobalPackageInstall(env) {
24
+ return env.npm_config_global === "true" ||
25
+ env.npm_config_global === "1" ||
26
+ env.npm_config_location === "global";
27
+ }
39
28
 
40
- /**
41
- * Pure decision function. Given an env snapshot and a "does `context` bin
42
- * exist on PATH?" predicate result, return either a hint to print or a
43
- * "do nothing" sentinel.
44
- *
45
- * Shape:
46
- * { action: "skip", reason: "<why>" }
47
- * | { action: "hint", lines: string[] }
48
- *
49
- * Every branch that reads process state in the real run is modeled here so
50
- * tests can pass synthetic envs. The caller decides whether to actually
51
- * print or set the re-entry guard.
52
- */
53
29
  export function decide(env, hasContextBin) {
54
- if (env[RE_ENTRY_ENV] === "1") {
55
- return { action: "skip", reason: "re-entry guard" };
56
- }
57
- if (env.CI === "true" || env.CI === "1") {
58
- return { action: "skip", reason: "CI" };
59
- }
60
- if (env.npm_config_global === "true") {
61
- return { action: "skip", reason: "already global install" };
62
- }
63
- if (env.CONTEXT_CLI_SKIP_AUTO_LINK === "1") {
64
- return { action: "skip", reason: "explicit opt-out" };
65
- }
66
- if (!looksLikeClaudePluginInstall(env)) {
67
- return { action: "skip", reason: "not a Claude plugin install" };
68
- }
69
- if (hasContextBin) {
70
- return { action: "skip", reason: "context already on PATH" };
30
+ if (env[RE_ENTRY_ENV] === "1") return { action: "skip", reason: "re-entry guard" };
31
+ if (env.CI === "true" || env.CI === "1") return { action: "skip", reason: "CI" };
32
+ if (env[SKIP_PLUGIN_INSTALL_ENV] === "1") return { action: "skip", reason: "explicit opt-out" };
33
+ if (env[FORCE_PLUGIN_INSTALL_ENV] === "1" || isGlobalPackageInstall(env)) {
34
+ return { action: "install" };
71
35
  }
36
+ if (!looksLikeClaudePluginInstall(env)) return { action: "skip", reason: "local dependency install" };
37
+ if (hasContextBin) return { action: "skip", reason: "context already on PATH" };
72
38
  const version = env.npm_package_version ?? "latest";
73
39
  return {
74
40
  action: "hint",
75
41
  lines: [
76
- "Claude plugin installed. To enable `/context:*` slash commands, make " +
77
- "the `context` bin available on PATH by running one of:",
78
- ` bun install -g @c4a/context-cli@${version} # install from npm registry`,
79
- " bun link # if installing a local build",
42
+ "Claude plugin installed without a global context CLI. Install it with:",
43
+ ` npm install -g @c4a/context-cli@${version}`,
80
44
  ],
81
45
  };
82
46
  }
83
47
 
84
- /**
85
- * Heuristic: does the caller's env smell like a Claude plugin install?
86
- * We require the env var's *value* to contain "claude" (case-insensitive),
87
- * not just the var to be set — `npm_config_user_agent` is set by every
88
- * install, and `CLAUDE_PLUGIN_INSTALL=0` should not trigger us either.
89
- */
90
- export function looksLikeClaudePluginInstall(env) {
91
- const hints = ["CLAUDE_PLUGIN_INSTALL", "CLAUDE_CODE_PLUGIN", "npm_config_user_agent"];
92
- for (const key of hints) {
93
- const value = env[key];
94
- if (!value) continue;
95
- if (value.toLowerCase().includes("claude")) return true;
96
- }
97
- return false;
98
- }
99
-
100
- /**
101
- * Real-world `hasContextBin` probe: shell out to `command -v` / `where`.
102
- * Kept separate from `decide()` so tests can inject a synthetic value.
103
- */
104
48
  export function probeContextBin() {
105
49
  const lookup = process.platform === "win32"
106
50
  ? spawnSync("where", ["context"], { stdio: "ignore" })
@@ -108,55 +52,52 @@ export function probeContextBin() {
108
52
  return lookup.status === 0;
109
53
  }
110
54
 
111
- function log(msg) {
112
- process.stderr.write(`[context-cli postinstall] ${msg}\n`);
55
+ export function resolveBundledCliEntry(metaUrl) {
56
+ const scriptDir = dirname(fileURLToPath(metaUrl));
57
+ const candidates = [
58
+ join(scriptDir, "..", "cli.js"),
59
+ join(scriptDir, "..", "dist", "cli.js"),
60
+ ];
61
+ return candidates.find((candidate) => existsSync(candidate)) ?? null;
62
+ }
63
+
64
+ function log(message) {
65
+ process.stderr.write(`[context-cli postinstall] ${message}\n`);
66
+ }
67
+
68
+ function installPlugin(env) {
69
+ const cliEntry = resolveBundledCliEntry(import.meta.url);
70
+ if (cliEntry === null) {
71
+ log("plugin refresh skipped: bundled cli.js was not found; run `context plugin install` later.");
72
+ return;
73
+ }
74
+ log("refreshing global Context agent plugins...");
75
+ const result = spawnSync(process.execPath, [cliEntry, "plugin", "install"], {
76
+ env: { ...env, [RE_ENTRY_ENV]: "1" },
77
+ stdio: "inherit",
78
+ });
79
+ if (result.error !== undefined) {
80
+ log(`plugin refresh failed: ${result.error.message}; run \`context plugin install\` later.`);
81
+ } else if (result.status !== 0) {
82
+ log(`plugin refresh exited with code ${result.status ?? "unknown"}; run \`context plugin install\` later.`);
83
+ } else {
84
+ log("global Context agent plugins refreshed.");
85
+ }
113
86
  }
114
87
 
115
88
  function main() {
116
- const env = process.env;
117
- // First pass with hasContextBin=false tells us whether every *other* gate
118
- // was passed. Only if the decision is "hint" do we need the real PATH
119
- // probe (avoids shelling out to sh/where in the common skip cases).
120
- const provisional = decide(env, false);
89
+ const provisional = decide(process.env, false);
90
+ if (provisional.action === "install") {
91
+ installPlugin(process.env);
92
+ return;
93
+ }
121
94
  if (provisional.action === "skip") return;
122
-
123
- // Decision said "hint" — now probe the real PATH. If context IS on PATH,
124
- // the user doesn't need the hint.
125
- const final = decide(env, probeContextBin());
95
+ const final = decide(process.env, probeContextBin());
126
96
  if (final.action === "hint") {
127
97
  for (const line of final.lines) log(line);
128
98
  }
129
- // Set the re-entry guard so any child process we may spawn in the future
130
- // (we currently don't, but belt-and-suspenders against recursion) will
131
- // inherit it and skip their own postinstall.
132
- env[RE_ENTRY_ENV] = "1";
133
99
  }
134
100
 
135
- // Side-effect wrapper only runs when this file is executed directly as a
136
- // script (the postinstall lifecycle does that). When the file is imported
137
- // by a test, `main()` is not invoked.
138
- //
139
- // IMPORTANT — two quirks that the obvious
140
- // `import.meta.url === \`file://${process.argv[1]}\``
141
- // check silently fails on, producing an invisible-no-op postinstall:
142
- //
143
- // 1. **Percent-encoding**: `import.meta.url` is always WHATWG-encoded
144
- // (`%20` for spaces, %-hex for many punctuation chars, uppercase
145
- // Windows drive letters, UNC `\\?\` prefixes). Raw `argv[1]` isn't.
146
- // A package install under `/Users/Dev Tools/...` reproducibly no-ops
147
- // with the interpolated check; going through `pathToFileURL` fixes
148
- // it.
149
- // 2. **Symlink resolution**: on macOS `/tmp` is a symlink to
150
- // `/private/tmp`, and `import.meta.url` resolves that; `argv[1]`
151
- // does not. npm stashes packages under symlinked paths on some
152
- // setups (npm's cache, nvm prefixes, corporate homedir mounts). We
153
- // realpath `argv[1]` before URL-encoding so both sides compare the
154
- // canonical target.
155
- //
156
- // Either quirk alone turns the postinstall script into a silent no-op —
157
- // exactly the invisible-failure mode npm postinstall scripts are
158
- // infamous for. The combined check below is validated by the subprocess
159
- // tests in postinstall.test.ts.
160
101
  export function isDirectRun(metaUrl, argv1) {
161
102
  if (!argv1) return false;
162
103
  try {
@@ -164,9 +105,7 @@ export function isDirectRun(metaUrl, argv1) {
164
105
  try {
165
106
  resolved = realpathSync(argv1);
166
107
  } catch {
167
- // argv[1] may not exist yet (weird, but possible under npm install
168
- // lifecycle timing). Fall back to the raw path — pathToFileURL
169
- // still normalizes encoding, so spaces-but-no-symlinks still works.
108
+ // Keep the raw path when lifecycle timing makes argv[1] unavailable.
170
109
  }
171
110
  return metaUrl === pathToFileURL(resolved).href;
172
111
  } catch {
@@ -177,7 +116,7 @@ export function isDirectRun(metaUrl, argv1) {
177
116
  if (isDirectRun(import.meta.url, process.argv[1])) {
178
117
  try {
179
118
  main();
180
- } catch (err) {
181
- log(`postinstall error: ${err instanceof Error ? err.message : String(err)}`);
119
+ } catch (error) {
120
+ log(`postinstall error: ${error instanceof Error ? error.message : String(error)}`);
182
121
  }
183
122
  }