claude-dev-env 7.6.0 → 7.6.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.
@@ -1,7 +1,10 @@
1
1
  # Session continuity companion
2
2
 
3
3
  Status: draft implementation. Claude, Codex, and Cursor adapters have
4
- repository-level contract tests. Installed-host activation, reload, and agent
4
+ repository-level contract tests. Claude installed-host automatic activation is
5
+ verified: a headless `claude -p` session on Claude Code 2.1.268 created a new
6
+ session-scope record carrying the `automatic` flag, with no user invocation.
7
+ Codex and Cursor installed-host activation, post-compaction reload, and agent
5
8
  compliance remain unverified.
6
9
 
7
10
  Every supported host activates Poteto Mode automatically when a session starts
@@ -27,21 +30,28 @@ pstack manifest refresher exposes namespaced Claude skills and leaves pstack
27
30
  content with its existing owner. The companion edits none of that source,
28
31
  manifest, invocation code, or model policy.
29
32
 
30
- From this checkout, run the full existing installer, then the focused hook setup:
33
+ From this checkout, run the full existing installer:
31
34
 
32
35
  ```sh
33
36
  node packages/claude-dev-env/bin/install.mjs
37
+ ```
38
+
39
+ The full installer publishes this skill under the canonical agents home and
40
+ registers its hooks in every host configuration the install root resolves. No
41
+ second command is needed. A selective `--only <group>` install skips both steps,
42
+ and a plugin-only installation needs the canonical installed skill first. For
43
+ either of those, or to configure one host alone, run the focused setup:
44
+
45
+ ```sh
34
46
  node packages/claude-dev-env/bin/install-session-continuity.mjs
35
47
  ```
36
48
 
37
- The full installer publishes this skill under the canonical agents home. This
38
- setup is required once for each selected host/profile, not once per invocation.
39
- The existing selective `--only core` list does not include the companion in this
40
- draft. Use the full install above. Plugin-only installations need the same
41
- canonical installed skill before this setup can run.
49
+ The focused setup defaults to `claude codex cursor`. Pass one or more host names
50
+ to configure that subset alone. Registration is idempotent, so running it after a
51
+ full install reports each host as already configured.
42
52
 
43
- The setup defaults to `claude codex cursor`. Pass one or more host names to
44
- configure that subset alone.
53
+ An uninstall removes the companion's registrations along with its files, so no
54
+ host keeps a hook pointing at a script that is gone.
45
55
  It uses `bin/resolve-install-root.mjs`, including `CLAUDE_CONFIG_DIR` and
46
56
  `CODEX_HOME`. It merges only its own hook groups into Claude `settings.json` and
47
57
  Codex `hooks.json`, preserves other settings, writes a one-time backup before
@@ -44,41 +44,84 @@ export function mergeContinuityHooks(existing, host, script) {
44
44
  return mergedConfiguration;
45
45
  }
46
46
 
47
- async function main() {
48
- const requested = process.argv.slice(2);
49
- const selected = requested.length ? requested : supportedHosts;
47
+ export const CONTINUITY_SCRIPT_MARKER = '/session-continuity/continuity.mjs';
48
+
49
+ export function isContinuityHookCommand(command) {
50
+ return typeof command === 'string' && command.includes(CONTINUITY_SCRIPT_MARKER);
51
+ }
52
+
53
+ export function continuityScriptPath(roots) {
54
+ return join(roots.skillsInstallDirectory, 'session-continuity', 'continuity.mjs');
55
+ }
56
+
57
+ export function continuityHostConfigurationPaths(roots) {
58
+ return {
59
+ claude: join(roots.managedRoot, 'settings.json'),
60
+ codex: join(dirname(roots.codexRulesInstallDirectory), 'hooks.json'),
61
+ cursor: join(roots.cursorInstallDirectory, 'hooks.json'),
62
+ };
63
+ }
64
+
65
+ export function removeContinuityHooks(configuration) {
66
+ let removedCount = 0;
67
+ for (const [event, entries] of Object.entries(configuration.hooks || {})) {
68
+ const kept = entries.flatMap(entry => {
69
+ if (!entry.hooks) {
70
+ if (!isContinuityHookCommand(entry.command)) return [entry];
71
+ removedCount += 1;
72
+ return [];
73
+ }
74
+ const hooks = entry.hooks.filter(hook => !isContinuityHookCommand(hook.command));
75
+ removedCount += entry.hooks.length - hooks.length;
76
+ return hooks.length ? [{ ...entry, hooks }] : [];
77
+ });
78
+ if (kept.length) configuration.hooks[event] = kept;
79
+ else delete configuration.hooks[event];
80
+ }
81
+ return removedCount;
82
+ }
83
+
84
+ function continuityConfigurationPlan(host, path, script) {
85
+ if (!existsSync(dirname(path))) throw new Error(`Host config directory is absent: ${dirname(path)}`);
86
+ const existing = existsSync(path) ? readFileSync(path, 'utf8') : null;
87
+ const merged = mergeContinuityHooks(existing ? JSON.parse(existing) : {}, host, script);
88
+ return { host, path, existing, content: JSON.stringify(merged, null, 2) + '\n' };
89
+ }
90
+
91
+ function applyContinuityConfigurationPlan(plan) {
92
+ if (plan.existing === plan.content) return { host: plan.host, path: plan.path, changed: false };
93
+ if (plan.existing !== null) {
94
+ const backup = `${plan.path}.before-session-continuity`;
95
+ if (!existsSync(backup)) writeFileSync(backup, plan.existing, { flag: 'wx', mode: 0o600 });
96
+ }
97
+ const current = existsSync(plan.path) ? readFileSync(plan.path, 'utf8') : null;
98
+ if (current !== plan.existing) throw new Error(`Config changed during setup: ${plan.path}. Rerun setup.`);
99
+ const temporary = `${plan.path}.continuity-${process.pid}.tmp`;
100
+ writeFileSync(temporary, plan.content, { flag: 'wx', mode: 0o600 });
101
+ renameSync(temporary, plan.path);
102
+ if (readFileSync(plan.path, 'utf8') !== plan.content) throw new Error(`Config read-back mismatch: ${plan.path}`);
103
+ return { host: plan.host, path: plan.path, changed: true };
104
+ }
105
+
106
+ export function configureContinuityHosts(roots, selected) {
50
107
  const unsupported = selected.find(host => !supportedHosts.includes(host));
51
108
  if (unsupported) throw new Error(`Unsupported host ${unsupported}`);
52
- const { resolveInstallRoot } = await import('./resolve-install-root.mjs');
53
- const roots = resolveInstallRoot();
54
- const script = join(roots.skillsInstallDirectory, 'session-continuity', 'continuity.mjs');
109
+ const script = continuityScriptPath(roots);
55
110
  if (!existsSync(script) || !existsSync(join(dirname(script), 'SKILL.md'))) {
56
111
  throw new Error('Run the full claude-dev-env installer from this checkout first so the companion is in the canonical agents home.');
57
112
  }
58
- const paths = {
59
- claude: join(roots.managedRoot, 'settings.json'),
60
- codex: join(dirname(roots.codexRulesInstallDirectory), 'hooks.json'),
61
- cursor: join(roots.cursorInstallDirectory, 'hooks.json'),
62
- };
63
- const plans = selected.map(host => {
64
- const path = paths[host];
65
- if (!existsSync(dirname(path))) throw new Error(`Host config directory is absent: ${dirname(path)}`);
66
- const existing = existsSync(path) ? readFileSync(path, 'utf8') : null;
67
- return { path, existing, content: JSON.stringify(mergeContinuityHooks(existing ? JSON.parse(existing) : {}, host, script), null, 2) + '\n' };
68
- });
69
- for (const plan of plans) {
70
- if (plan.existing === plan.content) { console.log(`Already configured: ${plan.path}`); continue; }
71
- if (plan.existing !== null) {
72
- const backup = `${plan.path}.before-session-continuity`;
73
- if (!existsSync(backup)) writeFileSync(backup, plan.existing, { flag: 'wx', mode: 0o600 });
74
- }
75
- const current = existsSync(plan.path) ? readFileSync(plan.path, 'utf8') : null;
76
- if (current !== plan.existing) throw new Error(`Config changed during setup: ${plan.path}. Rerun setup.`);
77
- const temporary = `${plan.path}.continuity-${process.pid}.tmp`;
78
- writeFileSync(temporary, plan.content, { flag: 'wx', mode: 0o600 });
79
- renameSync(temporary, plan.path);
80
- if (readFileSync(plan.path, 'utf8') !== plan.content) throw new Error(`Config read-back mismatch: ${plan.path}`);
81
- console.log(`Configured and read back: ${plan.path}`);
113
+ const paths = continuityHostConfigurationPaths(roots);
114
+ return selected
115
+ .map(host => continuityConfigurationPlan(host, paths[host], script))
116
+ .map(applyContinuityConfigurationPlan);
117
+ }
118
+
119
+ async function main() {
120
+ const requested = process.argv.slice(2);
121
+ const selected = requested.length ? requested : supportedHosts;
122
+ const { resolveInstallRoot } = await import('./resolve-install-root.mjs');
123
+ for (const result of configureContinuityHosts(resolveInstallRoot(), selected)) {
124
+ console.log(`${result.changed ? 'Configured and read back' : 'Already configured'}: ${result.path}`);
82
125
  }
83
126
  console.log('Review and trust the new hooks in each host.');
84
127
  }
@@ -1,6 +1,30 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import test from 'node:test';
3
- import { continuityHookConfiguration, mergeContinuityHooks } from './install-session-continuity.mjs';
3
+ import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import {
7
+ configureContinuityHosts,
8
+ continuityHookConfiguration,
9
+ mergeContinuityHooks,
10
+ removeContinuityHooks,
11
+ } from './install-session-continuity.mjs';
12
+
13
+ function installRootFixture() {
14
+ const root = mkdtempSync(join(tmpdir(), 'continuity-roots-'));
15
+ const skillsInstallDirectory = join(root, 'agents', 'skills');
16
+ const companionDirectory = join(skillsInstallDirectory, 'session-continuity');
17
+ mkdirSync(companionDirectory, { recursive: true });
18
+ writeFileSync(join(companionDirectory, 'continuity.mjs'), 'export default null;\n');
19
+ writeFileSync(join(companionDirectory, 'SKILL.md'), '# companion\n');
20
+ const managedRoot = join(root, 'claude');
21
+ const codexRulesInstallDirectory = join(root, 'codex', 'rules');
22
+ const cursorInstallDirectory = join(root, 'cursor');
23
+ for (const directory of [managedRoot, codexRulesInstallDirectory, cursorInstallDirectory]) {
24
+ mkdirSync(directory, { recursive: true });
25
+ }
26
+ return { managedRoot, skillsInstallDirectory, codexRulesInstallDirectory, cursorInstallDirectory };
27
+ }
4
28
 
5
29
  test('setup preserves other hooks and settings, is idempotent, and rejects competing profiles', () => {
6
30
  const existing = { permissions: { deny: ['secret'] }, hooks: { SessionStart: [{ hooks: [{ type: 'command', command: 'existing-hook' }] }] } };
@@ -53,3 +77,30 @@ for (const host of ['claude', 'codex']) {
53
77
  }
54
78
  });
55
79
  }
80
+
81
+ test('configureContinuityHosts writes each host config, reads it back, and repeats without a change', () => {
82
+ const roots = installRootFixture();
83
+ const settingsPath = join(roots.managedRoot, 'settings.json');
84
+ writeFileSync(settingsPath, JSON.stringify({ permissions: { deny: ['secret'] } }, null, 2) + '\n');
85
+ const first = configureContinuityHosts(roots, ['claude', 'codex', 'cursor']);
86
+ assert.deepEqual(first.map(result => result.changed), [true, true, true]);
87
+ const written = JSON.parse(readFileSync(settingsPath, 'utf8'));
88
+ assert.deepEqual(written.permissions.deny, ['secret']);
89
+ const command = written.hooks.SessionStart[0].hooks[0].command;
90
+ assert.equal(command.endsWith('session-continuity/continuity.mjs" hook claude'), true);
91
+ const second = configureContinuityHosts(roots, ['claude', 'codex', 'cursor']);
92
+ assert.deepEqual(second.map(result => result.changed), [false, false, false]);
93
+ });
94
+
95
+ test('removeContinuityHooks takes out only the companion registrations', () => {
96
+ const roots = installRootFixture();
97
+ configureContinuityHosts(roots, ['claude']);
98
+ const settingsPath = join(roots.managedRoot, 'settings.json');
99
+ const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
100
+ settings.hooks.SessionStart.unshift({ hooks: [{ type: 'command', command: 'python other_hook.py' }] });
101
+ const removedCount = removeContinuityHooks(settings);
102
+ assert.equal(removedCount, 3);
103
+ assert.deepEqual(Object.keys(settings.hooks), ['SessionStart']);
104
+ assert.equal(settings.hooks.SessionStart.length, 1);
105
+ assert.equal(settings.hooks.SessionStart[0].hooks[0].command, 'python other_hook.py');
106
+ });
package/bin/install.mjs CHANGED
@@ -44,6 +44,11 @@ import {
44
44
  refreshPstackPluginManifest,
45
45
  } from '../scripts/refresh_pstack_plugin_skills.mjs';
46
46
  import { installPstack } from './pstack.mjs';
47
+ import {
48
+ configureContinuityHosts,
49
+ continuityHostConfigurationPaths,
50
+ removeContinuityHooks,
51
+ } from './install-session-continuity.mjs';
47
52
  import {
48
53
  resolveInstallRoot,
49
54
  parseExplicitTargetFromArgv,
@@ -509,6 +514,63 @@ function seedPstackPreferences(preferenceFileName) {
509
514
  }
510
515
  }
511
516
 
517
+ /**
518
+ * Registers the session-continuity companion in each host configuration this
519
+ * install root resolves. Without this step the companion ships to the agents
520
+ * home but no host ever calls it, so Poteto Mode only activates when someone
521
+ * remembers a second setup command.
522
+ *
523
+ * A competing profile's registration, or an unwritable host configuration, is
524
+ * reported and leaves the rest of the install intact.
525
+ *
526
+ * @returns {string[]} The host configuration paths this run registered.
527
+ */
528
+ function registerSessionContinuityHooks() {
529
+ const configurationPaths = continuityHostConfigurationPaths(INSTALL_ROOT_RESOLUTION);
530
+ const hosts = Object.keys(configurationPaths)
531
+ .filter(host => existsSync(dirname(configurationPaths[host])));
532
+ if (hosts.length === 0) return [];
533
+ try {
534
+ const results = configureContinuityHosts(INSTALL_ROOT_RESOLUTION, hosts);
535
+ for (const result of results) {
536
+ const state = result.changed ? 'registered' : 'already registered';
537
+ console.log(` Session continuity: ${state} in ${result.path}`);
538
+ }
539
+ return results.map(result => result.path);
540
+ } catch (registrationError) {
541
+ console.warn(
542
+ ` Warning: session-continuity hooks were not registered (${registrationError.message}) — `
543
+ + 'run bin/install-session-continuity.mjs once the cause is resolved.',
544
+ );
545
+ return [];
546
+ }
547
+ }
548
+
549
+ /**
550
+ * Removes the companion's registrations from the Codex and Cursor hook files
551
+ * during an uninstall. The Claude settings.json copy is pruned inside the
552
+ * settings block, which owns that file's single write.
553
+ *
554
+ * Each host this installer registered is a host it has to clean, or the host
555
+ * keeps calling a script the uninstall deleted.
556
+ *
557
+ * @returns {string[]} The host configuration paths this run changed.
558
+ */
559
+ function removeSessionContinuityHooksFromOtherHosts() {
560
+ const configurationPaths = continuityHostConfigurationPaths(INSTALL_ROOT_RESOLUTION);
561
+ const changedPaths = [];
562
+ for (const host of ['codex', 'cursor']) {
563
+ const configurationPath = configurationPaths[host];
564
+ if (!existsSync(configurationPath)) continue;
565
+ const configuration = JSON.parse(readFileSync(configurationPath, 'utf8'));
566
+ if (removeContinuityHooks(configuration) === 0) continue;
567
+ writeFileSync(configurationPath, JSON.stringify(configuration, null, 2) + '\n');
568
+ changedPaths.push(configurationPath);
569
+ console.log(` Session continuity: hook registrations removed from ${configurationPath}`);
570
+ }
571
+ return changedPaths;
572
+ }
573
+
512
574
  /**
513
575
  * Formats an absolute interpreter path as a settings.json hook command prefix:
514
576
  * forward-slash separators, double-quoted when the path contains a space so the
@@ -2922,6 +2984,9 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
2922
2984
  console.log(` \u2713 ${relative(CLAUDE_HOME, agentsHubDest)} (canonical guidance)`);
2923
2985
  }
2924
2986
  const isFullInstall = !selectedGroups;
2987
+ if (isFullInstall && shouldInstallAnyHooks) {
2988
+ summary.sessionContinuity = { configuredPaths: registerSessionContinuityHooks() };
2989
+ }
2925
2990
  const didPruneRun = isFullInstall && UNRESOLVED_DEPENDENCY_NAMES.length === 0;
2926
2991
  let failedPrunePaths = [];
2927
2992
  let stalePrunedTotal = 0;
@@ -3139,6 +3204,10 @@ function executeUninstallPlan(plan, helpers = {}) {
3139
3204
  pruneManagedHooksFromSettings(settings, managedHookRelativePaths);
3140
3205
  didSettingsChange = true;
3141
3206
  console.log(' Hook entries removed from settings.json');
3207
+ const removedContinuityCount = removeContinuityHooks(settings);
3208
+ if (removedContinuityCount > 0) {
3209
+ console.log(` Session continuity: ${removedContinuityCount} hook registration(s) removed from settings.json`);
3210
+ }
3142
3211
  }
3143
3212
  const managedDenyFromPlan = plan.managedPermissionDenyEntries.length > 0
3144
3213
  ? plan.managedPermissionDenyEntries
@@ -3156,6 +3225,7 @@ function executeUninstallPlan(plan, helpers = {}) {
3156
3225
  writeFileSync(plan.settingsPath, JSON.stringify(settings, null, 4) + '\n');
3157
3226
  }
3158
3227
  }
3228
+ removeSessionContinuityHooksFromOtherHosts();
3159
3229
  throwIfFault(FAULT_PHASES.AFTER_SETTINGS_WRITE);
3160
3230
 
3161
3231
  unsetGlobalGitHooksPathIfOurs();
@@ -3088,3 +3088,60 @@ test('--no-pstack leaves the pstack store absent', t => {
3088
3088
  assert.equal(existsSync(join(homeDirectory, '.claude', 'pstack')), false);
3089
3089
  });
3090
3090
  });
3091
+
3092
+ function runContinuityInstaller(homeDirectory, extraArguments) {
3093
+ return execFileSync('node', [PSTACK_TEST_INSTALLER_PATH, ...extraArguments], {
3094
+ cwd: dirname(PSTACK_TEST_PACKAGE_ROOT),
3095
+ encoding: 'utf8',
3096
+ env: {
3097
+ ...process.env,
3098
+ CDE_INSTALL_PSTACK: '0',
3099
+ HOME: homeDirectory,
3100
+ USERPROFILE: homeDirectory,
3101
+ GIT_CONFIG_GLOBAL: join(homeDirectory, '.gitconfig'),
3102
+ CODEX_HOME: join(homeDirectory, '.codex'),
3103
+ },
3104
+ });
3105
+ }
3106
+
3107
+ function continuityCommandCount(configurationPath) {
3108
+ if (!existsSync(configurationPath)) return 0;
3109
+ const configuration = JSON.parse(readFileSync(configurationPath, 'utf8'));
3110
+ const allCommands = Object.values(configuration.hooks || {})
3111
+ .flat()
3112
+ .flatMap(entry => (entry.hooks ? entry.hooks.map(eachHook => eachHook.command) : [entry.command]));
3113
+ return allCommands.filter(
3114
+ eachCommand => typeof eachCommand === 'string'
3115
+ && eachCommand.includes('session-continuity/continuity.mjs'),
3116
+ ).length;
3117
+ }
3118
+
3119
+ test('a full install registers the session-continuity companion in every host, and uninstall takes it back out', t => {
3120
+ const homeDirectory = mkdtempSync(join(tmpdir(), 'cdev-continuity-install-'));
3121
+ t.after(() => rmSync(homeDirectory, { recursive: true, force: true }));
3122
+ const allConfigurationPaths = [
3123
+ join(homeDirectory, '.claude', 'settings.json'),
3124
+ join(homeDirectory, '.codex', 'hooks.json'),
3125
+ join(homeDirectory, '.cursor', 'hooks.json'),
3126
+ ];
3127
+
3128
+ runContinuityInstaller(homeDirectory, []);
3129
+
3130
+ for (const configurationPath of allConfigurationPaths) {
3131
+ assert.equal(
3132
+ continuityCommandCount(configurationPath) > 0,
3133
+ true,
3134
+ `the install registers the companion in ${configurationPath} without a second setup command`,
3135
+ );
3136
+ }
3137
+
3138
+ runContinuityInstaller(homeDirectory, ['--uninstall']);
3139
+
3140
+ for (const configurationPath of allConfigurationPaths) {
3141
+ assert.equal(
3142
+ continuityCommandCount(configurationPath),
3143
+ 0,
3144
+ `uninstall leaves no registration in ${configurationPath} pointing at a removed script`,
3145
+ );
3146
+ }
3147
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "7.6.0",
3
+ "version": "7.6.1",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {