halfcycle 0.4.2 → 0.4.4

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/dist/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // dist/bin.js
4
- import { existsSync as existsSync7, readFileSync as readFileSync11, realpathSync as realpathSync3, statSync } from "node:fs";
4
+ import { existsSync as existsSync7, readFileSync as readFileSync11, realpathSync as realpathSync3, statSync, writeSync as writeSync2 } from "node:fs";
5
5
  import { homedir as homedir3 } from "node:os";
6
6
  import { join as join12 } from "node:path";
7
7
 
@@ -1648,12 +1648,20 @@ function createProjectIdentityFile(targetRepoRoot) {
1648
1648
  }
1649
1649
 
1650
1650
  // dist/install-manifest.js
1651
+ import { Buffer as Buffer2 } from "node:buffer";
1651
1652
  import { createHash } from "node:crypto";
1652
1653
  var INSTALL_MANIFEST_REL = ".halfcycle/install-manifest.json";
1653
1654
  var INSTALL_MANIFEST_FORMAT = "halfcycle-install-manifest/v1";
1654
1655
  function sha256Hex(bytes) {
1655
1656
  return createHash("sha256").update(bytes).digest("hex");
1656
1657
  }
1658
+ function lineKey(line) {
1659
+ return line.endsWith("\r") ? line.slice(0, -1) : line;
1660
+ }
1661
+ function fileSha256(bytes) {
1662
+ const text = typeof bytes === "string" ? bytes : Buffer2.from(bytes).toString("utf-8");
1663
+ return sha256Hex(text.split("\n").map(lineKey).join("\n"));
1664
+ }
1657
1665
  function canonicalJson(value) {
1658
1666
  if (Array.isArray(value))
1659
1667
  return value.map(canonicalJson);
@@ -1836,7 +1844,7 @@ function startManifest(previous, isMember, isMemberDir) {
1836
1844
  };
1837
1845
  }
1838
1846
  function recordInstalledFile(draft, path, bytes) {
1839
- draft.files.set(path, { path, sha256: sha256Hex(bytes) });
1847
+ draft.files.set(path, { path, sha256: fileSha256(bytes) });
1840
1848
  }
1841
1849
  function recordRemovedFile(draft, path) {
1842
1850
  draft.files.delete(path);
@@ -1942,8 +1950,20 @@ function projectPageUrl(engagementId) {
1942
1950
  }
1943
1951
 
1944
1952
  // dist/merge-settings.js
1953
+ function isHalfcycleCommand(hook, halfcycleCommands) {
1954
+ if (typeof hook !== "object" || hook === null)
1955
+ return false;
1956
+ const command = hook.command;
1957
+ return typeof command === "string" && halfcycleCommands.has(command);
1958
+ }
1945
1959
  function isHalfcycleEntry(entry, halfcycleCommands) {
1946
- return entry.hooks.some((h) => halfcycleCommands.has(h.command));
1960
+ return entry.hooks.some((h) => isHalfcycleCommand(h, halfcycleCommands));
1961
+ }
1962
+ function withoutHalfcycleCommands(entry, halfcycleCommands) {
1963
+ if (!isHalfcycleEntry(entry, halfcycleCommands))
1964
+ return entry;
1965
+ const remaining = entry.hooks.filter((h) => !isHalfcycleCommand(h, halfcycleCommands));
1966
+ return remaining.length > 0 ? { ...entry, hooks: remaining } : null;
1947
1967
  }
1948
1968
  function mergeSettings(existing, generated) {
1949
1969
  const merged = { ...existing };
@@ -1962,7 +1982,7 @@ function mergeSettings(existing, generated) {
1962
1982
  const mergedHooks = {};
1963
1983
  const events = /* @__PURE__ */ new Set([...Object.keys(existingHooks), ...Object.keys(generatedHooks)]);
1964
1984
  for (const event of events) {
1965
- const developerEntries = (existingHooks[event] ?? []).filter((entry) => !isHalfcycleEntry(entry, halfcycleCommands));
1985
+ const developerEntries = (existingHooks[event] ?? []).flatMap((entry) => withoutHalfcycleCommands(entry, halfcycleCommands) ?? []);
1966
1986
  const halfcycleEntries = generatedHooks[event] ?? [];
1967
1987
  mergedHooks[event] = [...developerEntries, ...halfcycleEntries];
1968
1988
  }
@@ -2130,6 +2150,7 @@ function closedWriteSet() {
2130
2150
  ownedDirs,
2131
2151
  hookCommands: new Set(HALFCYCLE_HOOK_COMMANDS),
2132
2152
  denyRuleSha256: new Set([...DENY_PATTERNS, ...RETIRED_DENY_PATTERNS].map((rule) => sha256Hex(rule))),
2153
+ settingsSchema: SETTINGS_SCHEMA,
2133
2154
  gitignoreLines: /* @__PURE__ */ new Set([GITIGNORE_HEADER, ...REQUIRED_GITIGNORE_ENTRIES, LEGACY_ENV_LOCAL_REL]),
2134
2155
  gitattributesLines: /* @__PURE__ */ new Set([
2135
2156
  GITATTRIBUTES_HEADER,
@@ -2142,17 +2163,17 @@ function sha256OfWhatThisVersionWrites(commands) {
2142
2163
  const out = /* @__PURE__ */ new Map();
2143
2164
  const fromFile = (rel, src) => {
2144
2165
  if (existsSync3(src))
2145
- out.set(rel, sha256Hex(readFileSync5(src)));
2166
+ out.set(rel, fileSha256(readFileSync5(src)));
2146
2167
  };
2147
2168
  for (const cmd of commands)
2148
2169
  fromFile(commandStubRel(cmd.name), join5(BUNDLE_ROOT, ".claude-plugin", cmd.path));
2149
2170
  for (const [rel, content] of hookScriptsThisVersionWrites())
2150
- out.set(rel, sha256Hex(content));
2171
+ out.set(rel, fileSha256(content));
2151
2172
  fromFile(CAPTURED_INDEX_REL, capturedIndexScaffold());
2152
2173
  return out;
2153
2174
  }
2154
2175
  function bytesAreOurs(path, bytes, recorded, writeSet) {
2155
- const hash = sha256Hex(bytes);
2176
+ const hash = fileSha256(bytes);
2156
2177
  const recordedHash = recorded !== void 0 && "sha256" in recorded ? recorded.sha256 : void 0;
2157
2178
  return hash === recordedHash || hash === writeSet.generatedSha256.get(path);
2158
2179
  }
@@ -2318,6 +2339,8 @@ function copyManifestCommands(manifest, targetRepo, report, draft, writeSet) {
2318
2339
  record(report, outcome, rel);
2319
2340
  if (replacedExisting)
2320
2341
  report.replacedPaths.push(rel);
2342
+ if (outcome === "collided" && recorded !== void 0)
2343
+ report.editedCollidedPaths.push(rel);
2321
2344
  noteFile(draft, targetRepo, rel, outcome);
2322
2345
  }
2323
2346
  }
@@ -2338,16 +2361,17 @@ function writeCollisionSafe(targetAbsPath, targetRepoRoot, content, isOurs) {
2338
2361
  throw new Error(`[halfcycle] Write-allowlist violation: attempted to write "${rel}". Only the method surface and scaffolding paths are writable.`);
2339
2362
  }
2340
2363
  let replacedExisting = false;
2341
- if (existsSync3(targetAbsPath)) {
2342
- const current = readFileSync5(targetAbsPath);
2343
- if (current.toString("utf-8") === content)
2364
+ const current = existsSync3(targetAbsPath) ? readFileSync5(targetAbsPath) : null;
2365
+ const text = inLineEndingOf(current?.toString("utf-8") ?? null, content);
2366
+ if (current !== null) {
2367
+ if (current.toString("utf-8") === text)
2344
2368
  return { outcome: "skipped", replacedExisting: false };
2345
2369
  if (!isOurs(current))
2346
2370
  return { outcome: "collided", replacedExisting: false };
2347
2371
  replacedExisting = true;
2348
2372
  }
2349
2373
  mkdirSync5(dirname3(targetAbsPath), { recursive: true });
2350
- writeFileSync5(targetAbsPath, content, "utf-8");
2374
+ writeFileSync5(targetAbsPath, text, "utf-8");
2351
2375
  return { outcome: "written", replacedExisting };
2352
2376
  }
2353
2377
  var INSTALLER_OWNED_DIR = ".halfcycle/";
@@ -2359,11 +2383,12 @@ function writeOwned(targetAbsPath, targetRepoRoot, content) {
2359
2383
  if (!rel.startsWith(INSTALLER_OWNED_DIR)) {
2360
2384
  throw new Error(`[halfcycle] Path-ownership violation: attempted to claim "${rel}" by path. Only ${INSTALLER_OWNED_DIR} is owned outright by this installer; everywhere else a differing file may be a developer's, so ownership must be proved from the file's own contents (writeOwnedGenerated) rather than assumed from where it sits.`);
2361
2385
  }
2362
- if (existsSync3(targetAbsPath) && readFileSync5(targetAbsPath, "utf-8") === content) {
2386
+ const current = readFileOrNull(targetAbsPath);
2387
+ const text = inLineEndingOf(current, content);
2388
+ if (current === text)
2363
2389
  return "skipped";
2364
- }
2365
2390
  mkdirSync5(dirname3(targetAbsPath), { recursive: true });
2366
- writeFileSync5(targetAbsPath, content, "utf-8");
2391
+ writeFileSync5(targetAbsPath, text, "utf-8");
2367
2392
  return "written";
2368
2393
  }
2369
2394
  var GENERATED_HEADER_LINE_INDEX = 1;
@@ -2441,6 +2466,7 @@ function settingsParseRefusal(targetRepo, err) {
2441
2466
  }
2442
2467
  return `${file} could not be read as JSON${where} \u2014 fix it and run "npx halfcycle" again.`;
2443
2468
  }
2469
+ var SETTINGS_SCHEMA = "https://json.schemastore.org/claude-code-settings.json";
2444
2470
  function generateSettingsJson() {
2445
2471
  const hooks = {};
2446
2472
  for (const { event, matcher, timeout, command } of HALFCYCLE_HOOK_WIRING) {
@@ -2450,7 +2476,7 @@ function generateSettingsJson() {
2450
2476
  });
2451
2477
  }
2452
2478
  const settings = {
2453
- $schema: "https://json.schemastore.org/claude-code-settings.json",
2479
+ $schema: SETTINGS_SCHEMA,
2454
2480
  permissions: {
2455
2481
  // deny ONLY. See DENY_PATTERNS — an allow list evaporates under bypass mode
2456
2482
  // and is the machine owner's grant to make, not ours.
@@ -2750,19 +2776,27 @@ function adoptBlockUnderHeader(before, header, claimable) {
2750
2776
  let underHeader = false;
2751
2777
  const lines = before.split("\n");
2752
2778
  for (const [i, line] of lines.entries()) {
2753
- if (line === header) {
2754
- if (i > 0 && lines[i - 1] === "")
2755
- claimed.push("");
2779
+ const key = lineKey(line);
2780
+ if (key === header) {
2781
+ if (i > 0 && lineKey(lines[i - 1]) === "")
2782
+ claimed.push(lines[i - 1]);
2756
2783
  claimed.push(line);
2757
2784
  underHeader = true;
2758
- } else if (line.trim() === "") {
2785
+ } else if (key.trim() === "") {
2759
2786
  underHeader = false;
2760
- } else if (underHeader && claimable.has(line)) {
2787
+ } else if (underHeader && claimable.has(key)) {
2761
2788
  claimed.push(line);
2762
2789
  }
2763
2790
  }
2764
2791
  return claimed.length > 0 ? claimed.join("\n") + "\n" : "";
2765
2792
  }
2793
+ function lineEndingOf(text) {
2794
+ const at = text.indexOf("\n");
2795
+ return at > 0 && text[at - 1] === "\r" ? "\r\n" : "\n";
2796
+ }
2797
+ function inLineEndingOf(existing, generated) {
2798
+ return existing === null ? generated : generated.split("\n").join(lineEndingOf(existing));
2799
+ }
2766
2800
  function gitignoreCovers(content) {
2767
2801
  const lines = new Set(content.split("\n").map((l) => l.trim()));
2768
2802
  return REQUIRED_GITIGNORE_ENTRIES.every((entry) => lines.has(entry));
@@ -2781,9 +2815,8 @@ function reconcileGitignore(targetRepo) {
2781
2815
  return "skipped";
2782
2816
  const existingLines = new Set(existing.split("\n").map((l) => l.trim()));
2783
2817
  const toAdd = REQUIRED_GITIGNORE_ENTRIES.filter((entry) => !existingLines.has(entry));
2784
- const suffix = (existing.endsWith("\n") ? "" : "\n") + `
2785
- ${header}
2786
- ` + toAdd.join("\n") + "\n";
2818
+ const eol = lineEndingOf(existing);
2819
+ const suffix = (existing.endsWith("\n") ? "" : eol) + eol + [header, ...toAdd].join(eol) + eol;
2787
2820
  writeFileSync5(gitignorePath, existing + suffix, "utf-8");
2788
2821
  return "written";
2789
2822
  } catch {
@@ -2818,8 +2851,9 @@ function reconcileGitattributes(targetRepo) {
2818
2851
  const toAdd = rules.filter((rule) => !present.has(rule));
2819
2852
  if (toAdd.length === 0)
2820
2853
  return "skipped";
2821
- const separator = existing === "" ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
2822
- const suffix = separator + [GITATTRIBUTES_HEADER, ...toAdd].join("\n") + "\n";
2854
+ const eol = lineEndingOf(existing);
2855
+ const separator = existing === "" ? "" : existing.endsWith("\n") ? eol : eol + eol;
2856
+ const suffix = separator + [GITATTRIBUTES_HEADER, ...toAdd].join(eol) + eol;
2823
2857
  writeAllowlisted(path, targetRepo, existing + suffix, routed);
2824
2858
  return "written";
2825
2859
  } catch {
@@ -2839,9 +2873,9 @@ function previousPinForEngagement(targetRepoRoot, engagementId) {
2839
2873
  function nonEmpty(value) {
2840
2874
  return typeof value === "string" && value.trim() !== "" ? value : void 0;
2841
2875
  }
2842
- function writeBundlePin(targetRepoRoot, version, engagementId, engagementType, accountId, methodVersion, writtenPaths) {
2876
+ function writeBundlePin(targetRepoRoot, version, engagementId, engagementType, createdByAccountId, methodVersion, writtenPaths) {
2843
2877
  const previous = previousPinForEngagement(targetRepoRoot, engagementId);
2844
- const carried = nonEmpty(accountId) ?? nonEmpty(previous?.accountId);
2878
+ const carried = pinCreatedByAccountId(previous) ?? nonEmpty(createdByAccountId);
2845
2879
  const carriedMethodVersion = nonEmpty(methodVersion) ?? nonEmpty(previous?.methodVersion);
2846
2880
  const installedAt = nonEmpty(previous?.installedAt) ?? (/* @__PURE__ */ new Date()).toISOString();
2847
2881
  const projectPage = projectPageUrl(engagementId);
@@ -2850,12 +2884,13 @@ function writeBundlePin(targetRepoRoot, version, engagementId, engagementType, a
2850
2884
  engagementId,
2851
2885
  engagementType,
2852
2886
  installedAt,
2853
- ...carried !== void 0 ? { accountId: carried } : {},
2887
+ ...carried !== void 0 ? { createdByAccountId: carried } : {},
2854
2888
  ...carriedMethodVersion !== void 0 ? { methodVersion: carriedMethodVersion } : {},
2855
2889
  ...projectPage !== void 0 ? { projectPage } : {}
2856
2890
  };
2857
2891
  const pinPath = join5(targetRepoRoot, BUNDLE_PIN_REL);
2858
- writeAllowlisted(pinPath, targetRepoRoot, JSON.stringify(pin, null, 2) + "\n", writtenPaths);
2892
+ const pinText = inLineEndingOf(readFileOrNull(pinPath), JSON.stringify(pin, null, 2) + "\n");
2893
+ writeAllowlisted(pinPath, targetRepoRoot, pinText, writtenPaths);
2859
2894
  }
2860
2895
  function writeCrewRoster(targetRepoRoot, report) {
2861
2896
  const doc = {
@@ -2867,6 +2902,9 @@ function writeCrewRoster(targetRepoRoot, report) {
2867
2902
  const crewPath = join5(targetRepoRoot, CREW_ROSTER_REL);
2868
2903
  return recordOwned(report, crewPath, targetRepoRoot, rendered, CREW_ROSTER_REL);
2869
2904
  }
2905
+ function pinCreatedByAccountId(pin) {
2906
+ return nonEmpty(pin?.createdByAccountId) ?? nonEmpty(pin?.accountId);
2907
+ }
2870
2908
  function readBundlePin(targetRepoRoot) {
2871
2909
  const pinPath = join5(targetRepoRoot, BUNDLE_PIN_REL);
2872
2910
  if (!existsSync3(pinPath))
@@ -3008,7 +3046,7 @@ function migrateLegacyEnvLocal(targetRepo, stored) {
3008
3046
  }
3009
3047
  }
3010
3048
  async function install(options) {
3011
- const { targetRepo, engagementId, engagementType, credential, home, accountId } = options;
3049
+ const { targetRepo, engagementId, engagementType, credential, home, createdByAccountId } = options;
3012
3050
  assertEngagementId(engagementId);
3013
3051
  if (!existsSync3(targetRepo)) {
3014
3052
  throw new Error(`[halfcycle] Target repo does not exist: ${targetRepo}`);
@@ -3019,6 +3057,7 @@ async function install(options) {
3019
3057
  skippedPaths: [],
3020
3058
  mergedPaths: [],
3021
3059
  collidedPaths: [],
3060
+ editedCollidedPaths: [],
3022
3061
  replacedPaths: []
3023
3062
  };
3024
3063
  const writeSet = closedWriteSet();
@@ -3040,7 +3079,7 @@ async function install(options) {
3040
3079
  }
3041
3080
  recordSettingsMerge(draft, existingSettings, generatedSettings, settingsPreexisted);
3042
3081
  const mergedSettings = mergeSettings(existingSettings, generatedSettings);
3043
- const mergedSettingsText = JSON.stringify(mergedSettings, null, 2) + "\n";
3082
+ const mergedSettingsText = inLineEndingOf(readFileOrNull(settingsPath), JSON.stringify(mergedSettings, null, 2) + "\n");
3044
3083
  mkdirSync5(dirname3(settingsPath), { recursive: true });
3045
3084
  writeFileSync5(settingsPath, mergedSettingsText, "utf-8");
3046
3085
  (settingsPreexisted ? report.mergedPaths : report.writtenPaths).push(".claude/settings.json");
@@ -3068,7 +3107,7 @@ async function install(options) {
3068
3107
  if (credential) {
3069
3108
  const mcpPath = join5(targetRepo, MCP_REGISTRATION_REL);
3070
3109
  const existingMcp = existsSync3(mcpPath) ? readFileSync5(mcpPath, "utf-8") : null;
3071
- const mcpContent = generateMcpRegistration(existingMcp, credential.mcpUrl);
3110
+ const mcpContent = inLineEndingOf(existingMcp, generateMcpRegistration(existingMcp, credential.mcpUrl));
3072
3111
  recordMcpMerge(draft, existingMcp, mcpContent);
3073
3112
  if (existingMcp === null) {
3074
3113
  writeAllowlisted(mcpPath, targetRepo, mcpContent, report.writtenPaths);
@@ -3146,7 +3185,7 @@ async function install(options) {
3146
3185
  case "absent":
3147
3186
  break;
3148
3187
  }
3149
- writeBundlePin(targetRepo, manifest.version, engagementId, engagementType, accountId, credential?.methodVersion, report.writtenPaths);
3188
+ writeBundlePin(targetRepo, manifest.version, engagementId, engagementType, createdByAccountId, credential?.methodVersion, report.writtenPaths);
3150
3189
  noteFile(draft, targetRepo, BUNDLE_PIN_REL, "written");
3151
3190
  noteFile(draft, targetRepo, CREW_ROSTER_REL, writeCrewRoster(targetRepo, report));
3152
3191
  const scanResult = runBootstrapScan(targetRepo);
@@ -3166,6 +3205,7 @@ async function install(options) {
3166
3205
  skippedPaths: report.skippedPaths,
3167
3206
  mergedPaths: report.mergedPaths,
3168
3207
  collidedPaths: report.collidedPaths,
3208
+ editedCollidedPaths: report.editedCollidedPaths,
3169
3209
  replacedPaths: report.replacedPaths,
3170
3210
  bootstrapScanRan: scanResult.ran,
3171
3211
  legacyCredentialKept: migration.outcome === "kept" ? { keys: migration.unbacked, missing: migration.missingForAdoption } : null,
@@ -3606,7 +3646,7 @@ async function signIn(serviceUrl, deps = {}) {
3606
3646
  const url = loopbackVerificationUrl(started.verificationUrl, bind.target);
3607
3647
  const attempt = await openBrowser(url, env);
3608
3648
  if (attempt.opened) {
3609
- write(`[halfcycle] Opened your browser to finish signing in. Code: ${started.userCode}. If a browser did not open, go to: ${url}. This terminal is waiting.
3649
+ write(`[halfcycle] Opened your browser to finish signing in. Code: ${started.userCode}. If a browser did not open, go to: ${started.verificationUrl}. This terminal is waiting.
3610
3650
  `);
3611
3651
  const startedAt = now();
3612
3652
  const redirect = bind.waitForCallback({
@@ -4171,17 +4211,13 @@ async function askAccount(serviceUrl, credential, fetchImpl = fetch) {
4171
4211
  async function describeAccount(serviceUrl, credential, fetchImpl = fetch) {
4172
4212
  return (await askAccount(serviceUrl, credential, fetchImpl)).identity;
4173
4213
  }
4174
- function accountLabel(identity, storedAccountId, verbose2 = false, confirmed = true) {
4175
- const accountId = identity?.accountId ?? storedAccountId;
4176
- if (identity?.email !== void 0 && identity.email.trim() !== "") {
4177
- return verbose2 && accountId !== void 0 ? `${identity.email} (account ${accountId})` : identity.email;
4178
- }
4179
- if (!confirmed) {
4180
- return "the account this project is already set up with \u2014 this run could not reach the Halfcycle service to confirm who that is";
4214
+ function accountLabel(identity, storedAccountId, verbose2 = false) {
4215
+ if (identity === void 0)
4216
+ return void 0;
4217
+ if (identity.email !== void 0 && identity.email.trim() !== "") {
4218
+ return verbose2 ? `${identity.email} (account ${identity.accountId})` : identity.email;
4181
4219
  }
4182
- if (accountId !== void 0)
4183
- return `account ${accountId} (no email on record for it)`;
4184
- return "the account this credential belongs to, which this service did not name";
4220
+ return `account ${identity.accountId} (no email on record for it)`;
4185
4221
  }
4186
4222
  function onlyDate(iso) {
4187
4223
  if (iso === void 0)
@@ -4227,11 +4263,15 @@ function actingAs(credential, identity) {
4227
4263
  function question(colour) {
4228
4264
  return `[halfcycle] Continue as that account? ${ink("good", "[y]", colour)} yes ${ink("key", "[s]", colour)} sign in as someone else ${ink("faint", "[anything else]", colour)} stop: `;
4229
4265
  }
4230
- function armScript(credential, act, label, plainLabel, home) {
4266
+ function accountLine(identity, label) {
4267
+ return identity === void 0 ? "" : `[halfcycle] Account: ${label}
4268
+ `;
4269
+ }
4270
+ function armScript(credential, act, accountLineText, plainLabel, home) {
4231
4271
  switch (credential.source) {
4232
4272
  case "browser-handoff":
4233
4273
  return {
4234
- preamble: `[halfcycle] Signed in as ${label}. This install will ${actClause(act)}.
4274
+ preamble: `[halfcycle] This install will ${actClause(act)}.
4235
4275
  `,
4236
4276
  asks: false,
4237
4277
  noTerminalNote: "",
@@ -4239,11 +4279,11 @@ function armScript(credential, act, label, plainLabel, home) {
4239
4279
  stopRemedy: "",
4240
4280
  afterSwitch: ""
4241
4281
  };
4242
- case "environment":
4282
+ case "environment": {
4283
+ const credentialSentence = plainLabel !== void 0 ? `HALFCYCLE_TOKEN in this environment is the credential for ${plainLabel}, and it takes precedence over any saved sign-in` : `The credential in your environment could not be checked, because the Halfcycle service did not answer, and it takes precedence over any saved sign-in`;
4243
4284
  return {
4244
4285
  preamble: `[halfcycle] HALFCYCLE_TOKEN is set in this environment, and this install will use it.
4245
- [halfcycle] Account: ${label}
4246
- [halfcycle] About to ${actClause(act)}
4286
+ ` + accountLineText + `[halfcycle] About to ${actClause(act)}
4247
4287
  [halfcycle] A variable exported by a shell profile is not a choice about this run \u2014 if that is not your account, ${wrongAccountConsequence(act)}.
4248
4288
  `,
4249
4289
  asks: true,
@@ -4253,19 +4293,20 @@ function armScript(credential, act, label, plainLabel, home) {
4253
4293
  noTerminalNote: "",
4254
4294
  switchOpening: `[halfcycle] Signing you in through your browser \u2014 this install will use that account instead.
4255
4295
  `,
4256
- stopRemedy: `Stopped before anything was set up. HALFCYCLE_TOKEN in this environment is the credential for ${plainLabel}, and it takes precedence over any saved sign-in \u2014 so unset it, or set it to your own account's credential, before running this again.`,
4296
+ stopRemedy: `Stopped before anything was set up. ${credentialSentence} \u2014 so unset it, or set it to your own account's credential, before running this again.`,
4257
4297
  // The switch is real for THIS run (the new credential is what gets used) and it
4258
4298
  // does not persist: `obtainCredential` reads the variable first, so the next run
4259
4299
  // is the same variable again. Said here rather than discovered tomorrow.
4260
4300
  afterSwitch: `[halfcycle] HALFCYCLE_TOKEN is still set in this environment and still wins over a saved sign-in \u2014 unset it, or the next run will use it again.
4261
4301
  `
4262
4302
  };
4303
+ }
4263
4304
  case "stored": {
4264
4305
  const savedOn = onlyDate(credential.signedInAt);
4306
+ const savedSignInSentence = plainLabel !== void 0 ? `This machine's saved sign-in is for ${plainLabel}.` : `This machine's saved sign-in could not be checked, because the Halfcycle service did not answer.`;
4265
4307
  return {
4266
4308
  preamble: `[halfcycle] This machine already has a Halfcycle sign-in saved, and this install will use it.
4267
- [halfcycle] Account: ${label}
4268
- ` + (savedOn !== void 0 ? `[halfcycle] Saved on this machine: ${savedOn}
4309
+ ` + accountLineText + (savedOn !== void 0 ? `[halfcycle] Saved on this machine: ${savedOn}
4269
4310
  ` : "") + `[halfcycle] About to ${actClause(act)}
4270
4311
  [halfcycle] If that is not your account, ${wrongAccountConsequence(act)}${wrongAccountVisibility(act) === void 0 ? "" : ` \u2014 ${wrongAccountVisibility(act)}`}.
4271
4312
  `,
@@ -4275,11 +4316,11 @@ function armScript(credential, act, label, plainLabel, home) {
4275
4316
  // this is printed in), and where to look for the answer once this output is gone.
4276
4317
  noTerminalNote: `[halfcycle] Nothing here to ask at (no terminal), so this is continuing as that account.
4277
4318
  [halfcycle] To run as a different one, set HALFCYCLE_TOKEN for that account, or remove ${accountStorePath(home)} and run this again with a terminal to sign in.
4278
- [halfcycle] When this install finishes, which account it used is recorded in .halfcycle/bundle.json, so it can be checked after this output is gone.
4319
+ [halfcycle] .halfcycle/bundle.json records the account that created this project, so that can be checked after this output is gone.
4279
4320
  `,
4280
4321
  switchOpening: `[halfcycle] Forgetting this machine's saved sign-in and signing you in through your browser.
4281
4322
  `,
4282
- stopRemedy: `Stopped before anything was set up. This machine's saved sign-in is for ${plainLabel}. Run this again and answer "s" to sign in as a different account, or remove ${accountStorePath(home)} to be asked for a sign-in next time.`,
4323
+ stopRemedy: `Stopped before anything was set up. ${savedSignInSentence} Run this again and answer "s" to sign in as a different account, or remove ${accountStorePath(home)} to be asked for a sign-in next time.`,
4283
4324
  afterSwitch: ""
4284
4325
  };
4285
4326
  }
@@ -4294,9 +4335,9 @@ async function confirmActingIdentity(serviceUrl, credential, act, deps = {}) {
4294
4335
  const colour = deps.colour ?? decideColour({ isTTY: process.stdout.isTTY === true, env: deps.env ?? process.env });
4295
4336
  const identity = await describeAccount(serviceUrl, credential.credential, deps.fetchImpl);
4296
4337
  const plainLabel = accountLabel(identity, credential.accountId, deps.verbose === true);
4297
- const label = ink("keyBold", plainLabel, colour);
4338
+ const label = plainLabel !== void 0 ? ink("keyBold", plainLabel, colour) : "";
4298
4339
  const acting = actingAs(credential, identity);
4299
- const script = armScript(credential, act, label, plainLabel, deps.home);
4340
+ const script = armScript(credential, act, accountLine(identity, label), plainLabel, deps.home);
4300
4341
  write(script.preamble);
4301
4342
  if (!script.asks)
4302
4343
  return acting;
@@ -4320,7 +4361,7 @@ async function confirmActingIdentity(serviceUrl, credential, act, deps = {}) {
4320
4361
  write(script.switchOpening);
4321
4362
  const replacement = await switchAccount(serviceUrl, deps);
4322
4363
  const replacementIdentity = await describeAccount(serviceUrl, replacement.credential, deps.fetchImpl);
4323
- write(`[halfcycle] Signed in as ${ink("keyBold", accountLabel(replacementIdentity, replacement.accountId, deps.verbose === true), colour)}. This install will ${actClause(act)}.
4364
+ write(`[halfcycle] Signed in. This install will ${actClause(act)}.
4324
4365
  `);
4325
4366
  if (script.afterSwitch !== "")
4326
4367
  write(script.afterSwitch);
@@ -4629,9 +4670,9 @@ var UPGRADE_COMMANDS = {
4629
4670
  };
4630
4671
  var INSTALL_METHOD_RULES = [
4631
4672
  { method: "winget", pattern: /[\\/]WinGet[\\/]/i },
4632
- { method: "npm", pattern: /node_modules[\\/]/i },
4673
+ { method: "npm", pattern: /node_modules[\\/]|[\\/]npm[\\/]claude(\.cmd|\.exe)?$/i },
4633
4674
  { method: "brew", pattern: /[\\/](Cellar|Caskroom|linuxbrew|homebrew)[\\/]/i },
4634
- { method: "native", pattern: /[\\/]\.local[\\/]share[\\/]claude(-code)?[\\/]/i }
4675
+ { method: "native", pattern: /[\\/]\.local[\\/](share[\\/]claude(-code)?|bin)[\\/]/i }
4635
4676
  ];
4636
4677
  function probeClaudeCodeInstallMethod(deps = {}) {
4637
4678
  const env = deps.env ?? process.env;
@@ -5836,9 +5877,6 @@ function ourHookEntries(value, commands) {
5836
5877
  }
5837
5878
  return found;
5838
5879
  }
5839
- function isOurHook(hook, commands) {
5840
- return isPlainObject2(hook) && typeof hook["command"] === "string" && commands.has(hook["command"]);
5841
- }
5842
5880
  function ourMcpServers(value) {
5843
5881
  const servers = value["mcpServers"];
5844
5882
  return isPlainObject2(servers) && MCP_SERVER_KEY in servers ? servers : null;
@@ -5867,9 +5905,9 @@ function unmergeSettings(value, rec, writeSet) {
5867
5905
  continue;
5868
5906
  }
5869
5907
  touched = true;
5870
- const remaining = entry.hooks.filter((h) => !isOurHook(h, commands));
5871
- if (remaining.length > 0)
5872
- next.push({ ...entry, hooks: remaining });
5908
+ const remaining = withoutHalfcycleCommands(entry, commands);
5909
+ if (remaining !== null)
5910
+ next.push(remaining);
5873
5911
  }
5874
5912
  if (touched)
5875
5913
  changed = true;
@@ -5908,16 +5946,12 @@ function unmergeSettings(value, rec, writeSet) {
5908
5946
  changed = true;
5909
5947
  }
5910
5948
  }
5911
- if ("schemaBefore" in rec) {
5912
- if (rec.schemaBefore === null) {
5913
- if ("$schema" in value) {
5914
- delete value["$schema"];
5915
- changed = true;
5916
- }
5917
- } else if (value["$schema"] !== rec.schemaBefore) {
5949
+ if ("schemaBefore" in rec && value["$schema"] === writeSet.settingsSchema) {
5950
+ if (rec.schemaBefore === null)
5951
+ delete value["$schema"];
5952
+ else
5918
5953
  value["$schema"] = rec.schemaBefore;
5919
- changed = true;
5920
- }
5954
+ changed = true;
5921
5955
  }
5922
5956
  return { changed, refused };
5923
5957
  }
@@ -5957,36 +5991,70 @@ function gitattributesRules(writeSet) {
5957
5991
  function trimAppendedBlocks(text, rec, rules) {
5958
5992
  const keptLines = [];
5959
5993
  const foreignLines = [];
5994
+ const eol = lineEndingOf(text);
5960
5995
  let current = text;
5961
5996
  for (const block of [...rec.appended].reverse()) {
5962
- const lines = block.split("\n").filter((line) => line !== "");
5997
+ const lines = block.split("\n").map(lineKey).filter((line) => line !== "");
5963
5998
  const foreign = lines.filter((line) => !rules.ourLines.has(line));
5964
5999
  foreignLines.push(...foreign);
5965
6000
  const ours = lines.filter((line) => rules.ourLines.has(line));
5966
6001
  const entries = ours.filter((line) => line !== rules.header);
5967
6002
  const kept = entries.filter((line) => rules.keepLine(line));
5968
6003
  keptLines.push(...kept);
5969
- if (kept.length === 0 && foreign.length === 0 && current.includes(block)) {
5970
- const at = current.lastIndexOf(block);
5971
- const head = current.slice(0, at);
5972
- const tail = current.slice(at + block.length);
5973
- current = head + (head !== "" && tail !== "" && !head.endsWith("\n") ? "\n" : "") + tail;
6004
+ const found = kept.length === 0 && foreign.length === 0 ? lastOccurrence(current, block) : null;
6005
+ if (found !== null) {
6006
+ const head = current.slice(0, found.start);
6007
+ const tail = current.slice(found.end);
6008
+ current = head + (head !== "" && tail !== "" && !head.endsWith("\n") ? eol : "") + tail;
5974
6009
  continue;
5975
6010
  }
5976
- const separated = block.startsWith("\n") || block.includes("\n\n");
6011
+ const flat = withoutLineEndCr(block).text;
6012
+ const separated = flat.startsWith("\n") || flat.includes("\n\n");
5977
6013
  const removable = ours.filter((line) => line === rules.header ? kept.length === 0 && foreign.length === 0 : !kept.includes(line));
5978
6014
  for (const line of removable) {
5979
6015
  const fileLines = current.split("\n");
5980
- const at = fileLines.lastIndexOf(line);
6016
+ const keys = fileLines.map(lineKey);
6017
+ const at = keys.lastIndexOf(line);
5981
6018
  if (at < 0)
5982
6019
  continue;
5983
- const withSeparator = line === rules.header && separated && at > 0 && fileLines[at - 1] === "";
6020
+ const withSeparator = line === rules.header && separated && at > 0 && keys[at - 1] === "";
5984
6021
  fileLines.splice(withSeparator ? at - 1 : at, withSeparator ? 2 : 1);
5985
6022
  current = fileLines.join("\n");
5986
6023
  }
5987
6024
  }
5988
6025
  return { text: current, keptLines, foreignLines };
5989
6026
  }
6027
+ function withoutLineEndCr(text) {
6028
+ let out = "";
6029
+ const at = [];
6030
+ let offset = 0;
6031
+ const lines = text.split("\n");
6032
+ for (const [i, line] of lines.entries()) {
6033
+ const key = lineKey(line);
6034
+ for (let j = 0; j < key.length; j++)
6035
+ at.push(offset + j);
6036
+ out += key;
6037
+ offset += line.length;
6038
+ if (i < lines.length - 1) {
6039
+ out += "\n";
6040
+ at.push(offset);
6041
+ offset += 1;
6042
+ }
6043
+ }
6044
+ return { text: out, at };
6045
+ }
6046
+ function lastOccurrence(text, block) {
6047
+ const needle = withoutLineEndCr(block).text;
6048
+ if (needle === "")
6049
+ return null;
6050
+ const hay = withoutLineEndCr(text);
6051
+ const found = hay.text.lastIndexOf(needle);
6052
+ if (found < 0)
6053
+ return null;
6054
+ const first = hay.at[found];
6055
+ const start = first > 0 && text[first] === "\n" && text[first - 1] === "\r" ? first - 1 : first;
6056
+ return { start, end: hay.at[found + needle.length - 1] + 1 };
6057
+ }
5990
6058
  function coveredPathExists(targetRepo, line) {
5991
6059
  const path = line.trim().replace(/^\//, "").replace(/\/$/, "");
5992
6060
  return path !== "" && lexists(join10(targetRepo, path));
@@ -6111,7 +6179,7 @@ function applyMergedJson(targetRepo, rule, file, deleteWhenEmpty, lists, name, e
6111
6179
  if (!changed)
6112
6180
  return;
6113
6181
  const empty = Object.keys(value).length === 0 && deleteWhenEmpty;
6114
- writeBack(targetRepo, rel, file.text, empty ? null : serializeJson(value), lists, name);
6182
+ writeBack(targetRepo, rel, file.text, empty ? null : inLineEndingOf(file.text, serializeJson(value)), lists, name);
6115
6183
  if (adopted && lexists(join10(targetRepo, rel))) {
6116
6184
  name(lists.leftAlone, `${rel} (Halfcycle's hooks were taken out. There is no record of this file from before the install, so a permission rule or "$schema" Halfcycle added may still be there \u2014 it cannot be told apart from your own)`, rel);
6117
6185
  }
@@ -6555,6 +6623,21 @@ ${USAGE}`);
6555
6623
  process.exit(1);
6556
6624
  return;
6557
6625
  }
6626
+ let interrupted = false;
6627
+ const onInstallSigint = () => {
6628
+ if (interrupted)
6629
+ return;
6630
+ interrupted = true;
6631
+ const kept = readFolderId(targetRepo) !== void 0 ? `${KEPT_FOLDER_ID_LINE}
6632
+ ` : "";
6633
+ try {
6634
+ writeSync2(2, `halfcycle: interrupted.
6635
+ ${kept}`);
6636
+ } catch {
6637
+ }
6638
+ process.exit(1);
6639
+ };
6640
+ process.on("SIGINT", onInstallSigint);
6558
6641
  try {
6559
6642
  const controlOrigin = resolveControlOrigin(process.env);
6560
6643
  const serviceUrl = controlOrigin.origin;
@@ -6575,13 +6658,14 @@ ${USAGE}`);
6575
6658
  let credential;
6576
6659
  let notThisAccountMessage;
6577
6660
  let actingAccountId;
6661
+ let createdByAccountId;
6578
6662
  let actingIdentity;
6579
6663
  let foundLine = "";
6580
6664
  let reusedWithoutFreshAnswer = false;
6581
6665
  if (reusable !== void 0 && pinned !== null) {
6582
6666
  engagementId = pinned.engagementId;
6583
6667
  credential = reusable;
6584
- actingAccountId = readBundlePin(targetRepo)?.accountId;
6668
+ actingAccountId = heldAnswer?.identity?.accountId;
6585
6669
  foundLine = `already set up here \u2014 reusing engagement ${shortId(pinned.engagementId)}`;
6586
6670
  reusedWithoutFreshAnswer = true;
6587
6671
  if (verbose) {
@@ -6631,6 +6715,7 @@ ${USAGE}`);
6631
6715
  `);
6632
6716
  }
6633
6717
  } else {
6718
+ const hadUsableIdBeforeThisRun = readFolderId(targetRepo) !== void 0;
6634
6719
  const projectId = runPreCreateChecks(targetRepo);
6635
6720
  const created = await createOwnedEngagement(serviceUrl, targetRepo, projectId, { assumeYes, verbose });
6636
6721
  if (isProjectOwnedElsewhere(created)) {
@@ -6642,6 +6727,7 @@ ${PROJECT_RESET_LINE}
6642
6727
  }
6643
6728
  engagementId = created.engagementId;
6644
6729
  actingAccountId = created.actingAccountId;
6730
+ createdByAccountId = created.actingAccountId;
6645
6731
  actingIdentity = created.actingIdentity;
6646
6732
  credential = {
6647
6733
  serviceUrl,
@@ -6661,7 +6747,7 @@ ${PROJECT_RESET_LINE}
6661
6747
  consoleUrl: created.consoleUrl,
6662
6748
  methodVersion: created.methodVersion
6663
6749
  };
6664
- foundLine = `no Halfcycle project here yet \u2014 creating one (${shortId(created.engagementId)})`;
6750
+ foundLine = hadUsableIdBeforeThisRun ? `picking up this folder's project (${shortId(created.engagementId)})` : `no Halfcycle project here yet \u2014 creating one (${shortId(created.engagementId)})`;
6665
6751
  if (verbose) {
6666
6752
  process.stdout.write(`[halfcycle] Created engagement ${created.engagementId}
6667
6753
  `);
@@ -6672,7 +6758,7 @@ ${PROJECT_RESET_LINE}
6672
6758
  engagementId,
6673
6759
  engagementType: engagementTypeRaw,
6674
6760
  credential,
6675
- accountId: actingAccountId
6761
+ createdByAccountId
6676
6762
  });
6677
6763
  if (verbose) {
6678
6764
  process.stdout.write(`[halfcycle] Installed v${result.version} into ${targetRepo}
@@ -6688,8 +6774,14 @@ ${PROJECT_RESET_LINE}
6688
6774
  `);
6689
6775
  }
6690
6776
  }
6691
- if (result.collidedPaths.length > 0) {
6692
- process.stdout.write(`[halfcycle] Collided (a name you already use \u2014 NOT overwritten): ${result.collidedPaths.join(", ")}
6777
+ const edited = new Set(result.editedCollidedPaths);
6778
+ const trueCollisions = result.collidedPaths.filter((path) => !edited.has(path));
6779
+ if (result.editedCollidedPaths.length > 0) {
6780
+ process.stdout.write(`[halfcycle] Kept (your edited copy of an older Halfcycle file \u2014 NOT overwritten): ${result.editedCollidedPaths.join(", ")}
6781
+ `);
6782
+ }
6783
+ if (trueCollisions.length > 0) {
6784
+ process.stdout.write(`[halfcycle] Collided (a name you already use \u2014 NOT overwritten): ${trueCollisions.join(", ")}
6693
6785
  `);
6694
6786
  }
6695
6787
  if (result.replacedPaths.length > 0) {
@@ -6744,8 +6836,10 @@ ${PROJECT_RESET_LINE}
6744
6836
  const identity = actingIdentity ?? heldAnswer?.identity ?? (credential && heldAnswer === void 0 ? await describeAccount(credential.serviceUrl, credential.token) : void 0);
6745
6837
  const written = result.writtenPaths.length;
6746
6838
  const merged = result.mergedPaths.length;
6747
- process.stdout.write(`[halfcycle] Signed in as ${accountLabel(identity, actingAccountId, verbose, controlOriginConfirmed)}
6839
+ if (identity !== void 0) {
6840
+ process.stdout.write(`[halfcycle] Signed in as ${accountLabel(identity, actingAccountId, verbose)}
6748
6841
  `);
6842
+ }
6749
6843
  process.stdout.write(`[halfcycle] Found: ${foundLine}
6750
6844
  `);
6751
6845
  process.stdout.write(`[halfcycle] Set up: ${written} file${written === 1 ? "" : "s"} written${merged > 0 ? ` (${merged} merged)` : ""}, ${credential ? "guards armed on every edit" : "not checked \u2014 this copy is not this account\u2019s project"}
@@ -6757,7 +6851,7 @@ ${PROJECT_RESET_LINE}
6757
6851
  process.stdout.write(`[halfcycle] Commit the files Halfcycle set up here, including ${BUNDLE_PIN_REL} and ${PROJECT_IDENTITY_REL}, so every copy of this project joins it and runs the same checks.
6758
6852
  `);
6759
6853
  } else if (credential) {
6760
- process.stdout.write(process.platform !== "win32" && !gitIsInstalled() ? `[halfcycle] Halfcycle needs Git, and it is not installed on this machine \u2014 get it from https://git-scm.com/downloads, then run "git init" here to get checks on every edit.
6854
+ process.stdout.write(process.platform !== "win32" && !gitIsInstalled() ? `[halfcycle] Halfcycle needs Git, and it is not installed on this machine \u2014 get it from https://git-scm.com/downloads to get checks on every edit.
6761
6855
  ` : `[halfcycle] This folder is not a git repository. Halfcycle's checks compare against your git history, so run "git init" here to get checks on every edit.
6762
6856
  `);
6763
6857
  }