halfcycle 0.3.35 → 0.3.36
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/.claude-plugin/plugin.json +1 -1
- package/README.md +13 -0
- package/bin/bin.bundle.mjs +274 -43
- package/dist/banner-facts.d.ts +6 -2
- package/dist/banner-facts.d.ts.map +1 -1
- package/dist/bin.js +273 -292
- package/dist/bin.js.map +3 -3
- package/dist/claude-version-probe.d.ts +44 -0
- package/dist/claude-version-probe.d.ts.map +1 -0
- package/dist/confirm-identity.d.ts +7 -0
- package/dist/confirm-identity.d.ts.map +1 -1
- package/dist/engagement-credential.d.ts +31 -23
- package/dist/engagement-credential.d.ts.map +1 -1
- package/dist/index.js +149 -227
- package/dist/index.js.map +1 -1
- package/dist/install-manifest.d.ts +32 -4
- package/dist/install-manifest.d.ts.map +1 -1
- package/dist/install.d.ts +52 -16
- package/dist/install.d.ts.map +1 -1
- package/dist/open-phase.d.ts +2 -1
- package/dist/open-phase.d.ts.map +1 -1
- package/dist/probe-mcp.d.ts +39 -7
- package/dist/probe-mcp.d.ts.map +1 -1
- package/dist/uninstall.d.ts +1 -1
- package/dist/uninstall.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/bin.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// dist/bin.js
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
4
|
+
import { existsSync as existsSync5, readFileSync as readFileSync10, realpathSync, statSync } from "node:fs";
|
|
5
|
+
import { homedir as homedir3 } from "node:os";
|
|
6
6
|
import { join as join10 } from "node:path";
|
|
7
7
|
|
|
8
8
|
// dist/install.js
|
|
@@ -35,6 +35,8 @@ var ENGAGEMENTS_DIR_NAME = "engagements";
|
|
|
35
35
|
var ENGAGEMENT_ENV_FILENAME = "env";
|
|
36
36
|
var NOT_THIS_ACCOUNT_MARKER_FILENAME = "not-this-account";
|
|
37
37
|
var ACCOUNT_STORE_FILENAME = "account.json";
|
|
38
|
+
var BUNDLE_PIN_REL = ".halfcycle/bundle.json";
|
|
39
|
+
var PIN_ENGAGEMENT_ID_FIELD = "engagementId";
|
|
38
40
|
var ENGAGEMENT_ID_PATTERN = "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$";
|
|
39
41
|
var ENGAGEMENT_ID_SHAPE = new RegExp(ENGAGEMENT_ID_PATTERN);
|
|
40
42
|
function isEngagementId(value) {
|
|
@@ -1218,6 +1220,7 @@ var ciTokenExchangeResponseSchema = z10.object({
|
|
|
1218
1220
|
expiresAt: utcIso8601,
|
|
1219
1221
|
engagementId: z10.string()
|
|
1220
1222
|
}).strict();
|
|
1223
|
+
var SWITCH_ACCOUNT_ANSWER = 'answer "s" when asked';
|
|
1221
1224
|
|
|
1222
1225
|
// ../events/dist/phase-record.js
|
|
1223
1226
|
import { z as z11 } from "zod";
|
|
@@ -1322,7 +1325,6 @@ function forgetStoredCredential(serviceUrl, home) {
|
|
|
1322
1325
|
var ENGAGEMENTS_DIR = ENGAGEMENTS_DIR_NAME;
|
|
1323
1326
|
var ENV_FILENAME = ENGAGEMENT_ENV_FILENAME;
|
|
1324
1327
|
var HALFCYCLE_DIR = HALFCYCLE_DIR_NAME;
|
|
1325
|
-
var PIN_ENGAGEMENT_ID_FIELD = "engagementId";
|
|
1326
1328
|
var InvalidEngagementIdError = class extends Error {
|
|
1327
1329
|
constructor(engagementId) {
|
|
1328
1330
|
const shown = JSON.stringify(engagementId.length > 80 ? `${engagementId.slice(0, 80)}\u2026` : engagementId);
|
|
@@ -1640,6 +1642,15 @@ function bool(value, field) {
|
|
|
1640
1642
|
throw new UnreadableManifestError(`"${field}" is not true or false`);
|
|
1641
1643
|
return value;
|
|
1642
1644
|
}
|
|
1645
|
+
function appendedTextRecord(value, field) {
|
|
1646
|
+
if (!isRecord(value))
|
|
1647
|
+
throw new UnreadableManifestError(`"${field}" is not an object`);
|
|
1648
|
+
onlyKeys(value, ["created", "appended"], `"${field}"`);
|
|
1649
|
+
return {
|
|
1650
|
+
created: bool(value["created"], `${field}.created`),
|
|
1651
|
+
appended: stringArray(value["appended"], `${field}.appended`)
|
|
1652
|
+
};
|
|
1653
|
+
}
|
|
1643
1654
|
function onlyKeys(value, allowed, where) {
|
|
1644
1655
|
const extra = Object.keys(value).filter((k) => !allowed.includes(k));
|
|
1645
1656
|
if (extra.length > 0)
|
|
@@ -1654,7 +1665,7 @@ function parseManifest(text) {
|
|
|
1654
1665
|
}
|
|
1655
1666
|
if (!isRecord(raw))
|
|
1656
1667
|
throw new UnreadableManifestError("it is not a JSON object");
|
|
1657
|
-
onlyKeys(raw, ["format", "files", "createdDirs", "leftAlone", "settings", "mcp", "gitignore"], "the manifest");
|
|
1668
|
+
onlyKeys(raw, ["format", "files", "createdDirs", "leftAlone", "settings", "mcp", "gitignore", "gitattributes"], "the manifest");
|
|
1658
1669
|
if (raw["format"] !== INSTALL_MANIFEST_FORMAT) {
|
|
1659
1670
|
throw new UnreadableManifestError(`its format is not ${INSTALL_MANIFEST_FORMAT}`);
|
|
1660
1671
|
}
|
|
@@ -1715,14 +1726,10 @@ function parseManifest(text) {
|
|
|
1715
1726
|
...typeof m["entrySha256"] === "string" ? { entrySha256: m["entrySha256"] } : {}
|
|
1716
1727
|
};
|
|
1717
1728
|
}
|
|
1718
|
-
|
|
1719
|
-
if (!isRecord(g))
|
|
1729
|
+
if (raw["gitignore"] === void 0)
|
|
1720
1730
|
throw new UnreadableManifestError('"gitignore" is missing');
|
|
1721
|
-
|
|
1722
|
-
const
|
|
1723
|
-
created: bool(g["created"], "gitignore.created"),
|
|
1724
|
-
appended: stringArray(g["appended"], "gitignore.appended")
|
|
1725
|
-
};
|
|
1731
|
+
const gitignore = appendedTextRecord(raw["gitignore"], "gitignore");
|
|
1732
|
+
const gitattributes = raw["gitattributes"] === void 0 ? void 0 : appendedTextRecord(raw["gitattributes"], "gitattributes");
|
|
1726
1733
|
const paths = files.map((f) => f.path);
|
|
1727
1734
|
if (new Set(paths).size !== paths.length)
|
|
1728
1735
|
throw new UnreadableManifestError('"files" names a path twice');
|
|
@@ -1737,7 +1744,8 @@ function parseManifest(text) {
|
|
|
1737
1744
|
leftAlone,
|
|
1738
1745
|
settings,
|
|
1739
1746
|
...mcp !== void 0 ? { mcp } : {},
|
|
1740
|
-
gitignore
|
|
1747
|
+
gitignore,
|
|
1748
|
+
...gitattributes !== void 0 ? { gitattributes } : {}
|
|
1741
1749
|
};
|
|
1742
1750
|
}
|
|
1743
1751
|
function startManifest(previous, isMember, isMemberDir) {
|
|
@@ -1753,12 +1761,16 @@ function startManifest(previous, isMember, isMemberDir) {
|
|
|
1753
1761
|
createdDirs: new Set((previous?.createdDirs ?? []).filter(isMemberDir)),
|
|
1754
1762
|
settings: previous?.settings,
|
|
1755
1763
|
mcp: previous?.mcp,
|
|
1756
|
-
gitignore: previous?.gitignore
|
|
1764
|
+
gitignore: previous?.gitignore,
|
|
1765
|
+
gitattributes: previous?.gitattributes
|
|
1757
1766
|
};
|
|
1758
1767
|
}
|
|
1759
1768
|
function recordInstalledFile(draft, path, bytes) {
|
|
1760
1769
|
draft.files.set(path, { path, sha256: sha256Hex(bytes) });
|
|
1761
1770
|
}
|
|
1771
|
+
function recordRemovedFile(draft, path) {
|
|
1772
|
+
draft.files.delete(path);
|
|
1773
|
+
}
|
|
1762
1774
|
function recordPerMachineFile(draft, path) {
|
|
1763
1775
|
draft.files.set(path, { path, perMachine: true });
|
|
1764
1776
|
}
|
|
@@ -1801,8 +1813,15 @@ function recordMcp(draft, observed) {
|
|
|
1801
1813
|
};
|
|
1802
1814
|
}
|
|
1803
1815
|
function recordGitignore(draft, created, appended) {
|
|
1804
|
-
|
|
1805
|
-
|
|
1816
|
+
draft.gitignore = carryAppended(draft.gitignore, created, appended);
|
|
1817
|
+
}
|
|
1818
|
+
function recordGitattributes(draft, created, appended) {
|
|
1819
|
+
if (draft.gitattributes === void 0 && !created && appended.every((a) => a === ""))
|
|
1820
|
+
return;
|
|
1821
|
+
draft.gitattributes = carryAppended(draft.gitattributes, created, appended);
|
|
1822
|
+
}
|
|
1823
|
+
function carryAppended(prev, created, appended) {
|
|
1824
|
+
return {
|
|
1806
1825
|
created: prev?.created ?? created,
|
|
1807
1826
|
appended: [...prev?.appended ?? [], ...appended.filter((a) => a !== "")]
|
|
1808
1827
|
};
|
|
@@ -1826,7 +1845,8 @@ function finishManifest(draft) {
|
|
|
1826
1845
|
denyCreated: false
|
|
1827
1846
|
},
|
|
1828
1847
|
...draft.mcp !== void 0 ? { mcp: draft.mcp } : {},
|
|
1829
|
-
gitignore: draft.gitignore ?? { created: false, appended: [] }
|
|
1848
|
+
gitignore: draft.gitignore ?? { created: false, appended: [] },
|
|
1849
|
+
...draft.gitattributes !== void 0 ? { gitattributes: draft.gitattributes } : {}
|
|
1830
1850
|
};
|
|
1831
1851
|
}
|
|
1832
1852
|
function union(a, b) {
|
|
@@ -1977,11 +1997,12 @@ var WRITE_ALLOWLIST = [
|
|
|
1977
1997
|
".claude/hooks",
|
|
1978
1998
|
".claude/settings.json",
|
|
1979
1999
|
".halfcycle",
|
|
1980
|
-
|
|
2000
|
+
BUNDLE_PIN_REL,
|
|
1981
2001
|
".halfcycle/bin",
|
|
1982
2002
|
"docs/build-records",
|
|
1983
2003
|
"test/fixtures/captured",
|
|
1984
2004
|
".mcp.json",
|
|
2005
|
+
".gitattributes",
|
|
1985
2006
|
ZONE_B_DIR
|
|
1986
2007
|
];
|
|
1987
2008
|
function isAllowlisted(targetRelPath) {
|
|
@@ -1991,15 +2012,16 @@ function isAllowlisted(targetRelPath) {
|
|
|
1991
2012
|
return normalised === p || normalised.startsWith(p + "/");
|
|
1992
2013
|
});
|
|
1993
2014
|
}
|
|
1994
|
-
var BUNDLE_PIN_REL = ".halfcycle/bundle.json";
|
|
1995
2015
|
var PROJECT_IDENTITY_REL = ".halfcycle/project.json";
|
|
1996
2016
|
var CREW_ROSTER_REL = ".halfcycle/crew.json";
|
|
1997
2017
|
var CAPTURED_INDEX_REL = "test/fixtures/captured/manifest.json";
|
|
1998
2018
|
var GITIGNORE_REL = ".gitignore";
|
|
2019
|
+
var GITATTRIBUTES_REL = ".gitattributes";
|
|
1999
2020
|
function commandStubRel(name) {
|
|
2000
2021
|
return `.claude/commands/${name}.md`;
|
|
2001
2022
|
}
|
|
2002
|
-
var
|
|
2023
|
+
var RETIRED_MCP_HEADERS_HELPER_REL = ".halfcycle/mcp-headers.sh";
|
|
2024
|
+
var RETIRED_WRITE_SET_FILES = [RETIRED_MCP_HEADERS_HELPER_REL];
|
|
2003
2025
|
var RETIRED_DENY_PATTERNS = [];
|
|
2004
2026
|
function closedWriteSet() {
|
|
2005
2027
|
const files = /* @__PURE__ */ new Set([
|
|
@@ -2014,7 +2036,7 @@ function closedWriteSet() {
|
|
|
2014
2036
|
INSTALL_MANIFEST_REL,
|
|
2015
2037
|
...RETIRED_WRITE_SET_FILES
|
|
2016
2038
|
]);
|
|
2017
|
-
const merged = /* @__PURE__ */ new Set([SETTINGS_REL, MCP_REGISTRATION_REL, GITIGNORE_REL]);
|
|
2039
|
+
const merged = /* @__PURE__ */ new Set([SETTINGS_REL, MCP_REGISTRATION_REL, GITIGNORE_REL, GITATTRIBUTES_REL]);
|
|
2018
2040
|
const dirs = /* @__PURE__ */ new Set();
|
|
2019
2041
|
for (const path of [...files, ...merged]) {
|
|
2020
2042
|
for (let dir = posix.dirname(path); dir !== "."; dir = posix.dirname(dir))
|
|
@@ -2028,7 +2050,11 @@ function closedWriteSet() {
|
|
|
2028
2050
|
ownedDirs,
|
|
2029
2051
|
hookCommands: new Set(HALFCYCLE_HOOK_COMMANDS),
|
|
2030
2052
|
denyRuleSha256: new Set([...DENY_PATTERNS, ...RETIRED_DENY_PATTERNS].map((rule) => sha256Hex(rule))),
|
|
2031
|
-
gitignoreLines: /* @__PURE__ */ new Set([GITIGNORE_HEADER, ...REQUIRED_GITIGNORE_ENTRIES, LEGACY_ENV_LOCAL_REL])
|
|
2053
|
+
gitignoreLines: /* @__PURE__ */ new Set([GITIGNORE_HEADER, ...REQUIRED_GITIGNORE_ENTRIES, LEGACY_ENV_LOCAL_REL]),
|
|
2054
|
+
gitattributesLines: /* @__PURE__ */ new Set([
|
|
2055
|
+
GITATTRIBUTES_HEADER,
|
|
2056
|
+
...lfRules([...Object.keys(OWNED_GENERATED_HEADERS), ...RETIRED_WRITE_SET_FILES])
|
|
2057
|
+
])
|
|
2032
2058
|
};
|
|
2033
2059
|
}
|
|
2034
2060
|
function readPreviousManifest(targetRepo) {
|
|
@@ -2092,15 +2118,19 @@ function recordMcpAdoptionOnly(draft, existingText) {
|
|
|
2092
2118
|
}
|
|
2093
2119
|
function recordGitignoreReconcile(draft, before, after) {
|
|
2094
2120
|
const adopted = draft.gitignore === void 0 && before !== null ? adoptOlderGitignoreBlock(before) : "";
|
|
2095
|
-
|
|
2096
|
-
if (after !== null && after !== before) {
|
|
2097
|
-
if (before === null)
|
|
2098
|
-
appended = after;
|
|
2099
|
-
else if (after.startsWith(before))
|
|
2100
|
-
appended = after.slice(before.length);
|
|
2101
|
-
}
|
|
2121
|
+
const appended = appendedText(before, after);
|
|
2102
2122
|
recordGitignore(draft, before === null && after !== null, [adopted, appended]);
|
|
2103
2123
|
}
|
|
2124
|
+
function recordGitattributesReconcile(draft, before, after) {
|
|
2125
|
+
recordGitattributes(draft, before === null && after !== null, [appendedText(before, after)]);
|
|
2126
|
+
}
|
|
2127
|
+
function appendedText(before, after) {
|
|
2128
|
+
if (after === null || after === before)
|
|
2129
|
+
return "";
|
|
2130
|
+
if (before === null)
|
|
2131
|
+
return after;
|
|
2132
|
+
return after.startsWith(before) ? after.slice(before.length) : "";
|
|
2133
|
+
}
|
|
2104
2134
|
function assertManifestInWriteSet(manifest, writeSet) {
|
|
2105
2135
|
const strays = [
|
|
2106
2136
|
...manifest.files.map((f) => f.path).filter((p) => !writeSet.files.has(p)),
|
|
@@ -2115,7 +2145,8 @@ function findNotShared(targetRepo, manifest, collidedPaths) {
|
|
|
2115
2145
|
const merged = [
|
|
2116
2146
|
SETTINGS_REL,
|
|
2117
2147
|
...manifest.mcp !== void 0 ? [MCP_REGISTRATION_REL] : [],
|
|
2118
|
-
...manifest.gitignore.created || manifest.gitignore.appended.length > 0 ? [GITIGNORE_REL] : []
|
|
2148
|
+
...manifest.gitignore.created || manifest.gitignore.appended.length > 0 ? [GITIGNORE_REL] : [],
|
|
2149
|
+
...manifest.gitattributes !== void 0 && (manifest.gitattributes.created || manifest.gitattributes.appended.length > 0) ? [GITATTRIBUTES_REL] : []
|
|
2119
2150
|
];
|
|
2120
2151
|
const collided = new Set(collidedPaths);
|
|
2121
2152
|
const dirVerdicts = /* @__PURE__ */ new Map();
|
|
@@ -2484,7 +2515,8 @@ set -e
|
|
|
2484
2515
|
|
|
2485
2516
|
# The project root is resolved from THIS SCRIPT'S OWN LOCATION, never from the
|
|
2486
2517
|
# session's cwd \u2014 a session started in a subdirectory must find the same root
|
|
2487
|
-
# .mcp.json the editor loaded, which is the bug
|
|
2518
|
+
# .mcp.json the editor loaded, which is the bug the editor's headers command
|
|
2519
|
+
# already paid for.
|
|
2488
2520
|
HC_SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
|
2489
2521
|
HC_PROJECT_ROOT=$(CDPATH= cd -- "$HC_SCRIPT_DIR/../.." && pwd)
|
|
2490
2522
|
HC_REGISTRATION="$HC_PROJECT_ROOT/${MCP_REGISTRATION_REL}"
|
|
@@ -2531,7 +2563,9 @@ function isTrackedAndClean(targetRepo, relPath) {
|
|
|
2531
2563
|
return true;
|
|
2532
2564
|
}
|
|
2533
2565
|
function removeLegacyCiStanza(targetRepo) {
|
|
2534
|
-
|
|
2566
|
+
return removeRetiredGeneratedFile(targetRepo, ".halfcycle/ci-stanza.yml", 0, CI_STANZA_KNOWN_HEADER_HASHES);
|
|
2567
|
+
}
|
|
2568
|
+
function removeRetiredGeneratedFile(targetRepo, rel, headerLineIndex, knownHeaderHashes) {
|
|
2535
2569
|
const path = join5(targetRepo, rel);
|
|
2536
2570
|
if (!existsSync3(path))
|
|
2537
2571
|
return "absent";
|
|
@@ -2541,10 +2575,9 @@ function removeLegacyCiStanza(targetRepo) {
|
|
|
2541
2575
|
} catch {
|
|
2542
2576
|
return "failed";
|
|
2543
2577
|
}
|
|
2544
|
-
const headerLine = raw.split("\n")[
|
|
2578
|
+
const headerLine = raw.split("\n")[headerLineIndex] ?? "";
|
|
2545
2579
|
const digest = createHash2("sha256").update(headerLine, "utf-8").digest("hex");
|
|
2546
|
-
|
|
2547
|
-
if (!ours)
|
|
2580
|
+
if (!knownHeaderHashes.includes(digest))
|
|
2548
2581
|
return "kept-foreign";
|
|
2549
2582
|
if (!isTrackedAndClean(targetRepo, rel))
|
|
2550
2583
|
return "kept-uncommitted";
|
|
@@ -2557,203 +2590,33 @@ function removeLegacyCiStanza(targetRepo) {
|
|
|
2557
2590
|
}
|
|
2558
2591
|
var MCP_REGISTRATION_REL = ".mcp.json";
|
|
2559
2592
|
var MCP_SERVER_KEY = "halfcycle";
|
|
2560
|
-
var
|
|
2561
|
-
|
|
2562
|
-
|
|
2593
|
+
var MCP_HEADERS_HELPER_COMMAND = `node -e "${[
|
|
2594
|
+
`const p=require('path'),f=require('fs'),c=require('child_process');`,
|
|
2595
|
+
`let d=process.cwd();`,
|
|
2596
|
+
`for(;;){`,
|
|
2597
|
+
`const b=p.join(d,'${VENDORED_BIN_REL}');`,
|
|
2598
|
+
`if(f.existsSync(b)){`,
|
|
2599
|
+
`const r=c.spawnSync(process.execPath,[b,'mcp-headers'],{stdio:'inherit'});`,
|
|
2600
|
+
`process.exit(typeof r.status==='number'?r.status:1)}`,
|
|
2601
|
+
`const u=p.dirname(d);`,
|
|
2602
|
+
`if(u===d)break;`,
|
|
2603
|
+
`d=u}`,
|
|
2604
|
+
`console.error('halfcycle: no ${VENDORED_BIN_REL} at or above '+process.cwd()+'; run npx halfcycle in the project folder');`,
|
|
2605
|
+
`process.exit(1)`
|
|
2606
|
+
].join("")}"`;
|
|
2607
|
+
var MCP_HEADERS_KNOWN_HEADER_HASHES = [
|
|
2608
|
+
"37a733ea588b3361b2bbfd86c44e0c95da30d7af6acffd1243ece83c811f0af2",
|
|
2609
|
+
"6732ec6d2d2bf7cb33017749a3d021b59af653dcd07c3811693b5a242d44b1a1"
|
|
2610
|
+
];
|
|
2611
|
+
function removeRetiredMcpHeadersHelper(targetRepo) {
|
|
2612
|
+
return removeRetiredGeneratedFile(targetRepo, RETIRED_MCP_HEADERS_HELPER_REL, 1, MCP_HEADERS_KNOWN_HEADER_HASHES);
|
|
2613
|
+
}
|
|
2563
2614
|
var OWNED_GENERATED_HEADERS = {
|
|
2564
2615
|
".claude/hooks/guard-runner.sh": GENERATED_GUARD_RUNNER_HEADER,
|
|
2565
2616
|
".claude/hooks/user-prompt-reminder.sh": GENERATED_USER_PROMPT_REMINDER_HEADER,
|
|
2566
2617
|
".claude/hooks/session-end-marker.sh": GENERATED_SESSION_END_HEADER,
|
|
2567
|
-
".claude/hooks/session-start-marker.sh": GENERATED_SESSION_START_HEADER
|
|
2568
|
-
".halfcycle/mcp-headers.sh": GENERATED_MCP_HEADERS_HEADER
|
|
2618
|
+
".claude/hooks/session-start-marker.sh": GENERATED_SESSION_START_HEADER
|
|
2569
2619
|
};
|
|
2570
|
-
function generateMcpHeadersHelper() {
|
|
2571
|
-
return `#!/bin/sh
|
|
2572
|
-
${GENERATED_MCP_HEADERS_HEADER} the Halfcycle installer.
|
|
2573
|
-
#
|
|
2574
|
-
# Claude Code runs this at MCP connection time, from the session's cwd, and merges
|
|
2575
|
-
# its stdout into the connection headers for the "halfcycle" server in .mcp.json.
|
|
2576
|
-
#
|
|
2577
|
-
# It exists because \${VAR} expansion in .mcp.json reads the PROCESS environment
|
|
2578
|
-
# only: nothing loads a credential into a session, so a referenced
|
|
2579
|
-
# \${HALFCYCLE_TOKEN} expanded to nothing and the server was silently absent from
|
|
2580
|
-
# the tool list.
|
|
2581
|
-
#
|
|
2582
|
-
# This file is tracked and holds NO secret. NEITHER DOES ANY OTHER FILE IN THIS
|
|
2583
|
-
# REPOSITORY (T-09): the credential lives at machine level, outside every checkout,
|
|
2584
|
-
# and this script READS one line out of it (never sources \u2014 a credential store is
|
|
2585
|
-
# not code).
|
|
2586
|
-
set -eu
|
|
2587
|
-
|
|
2588
|
-
# The project root is resolved from THIS SCRIPT'S OWN LOCATION, never from the
|
|
2589
|
-
# caller's working directory. Claude Code runs the helper from the SESSION's cwd,
|
|
2590
|
-
# and a session started in a subdirectory still finds the root .mcp.json \u2014 so a
|
|
2591
|
-
# relative read here silently produced no credential for anyone who started the
|
|
2592
|
-
# session in any subdirectory of a monorepo.
|
|
2593
|
-
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
|
2594
|
-
PROJECT_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)
|
|
2595
|
-
|
|
2596
|
-
${engagementResolutionShell()}
|
|
2597
|
-
|
|
2598
|
-
# Read one KEY's value out of the credential store into HC_VALUE, in the CALLER's
|
|
2599
|
-
# shell (call it as a statement, never inside $(...)). Last assignment wins,
|
|
2600
|
-
# matching the shell's own \`.\` semantics. An optional leading \`export \` is
|
|
2601
|
-
# tolerated because a hand-edited store may use one.
|
|
2602
|
-
halfcycle_store_value() {
|
|
2603
|
-
HC_VALUE=""
|
|
2604
|
-
hc_line=$(grep "^[[:space:]]*\\(export[[:space:]][[:space:]]*\\)\\{0,1\\}$2=" "$1" | tail -n 1)
|
|
2605
|
-
hc_v=\${hc_line#*"$2="}
|
|
2606
|
-
hc_v=$(printf '%s' "$hc_v" | tr -d '\\r')
|
|
2607
|
-
|
|
2608
|
-
# Strip one layer of matching quotes. The store is WRITTEN single-quoted (both
|
|
2609
|
-
# writers use the same shq: this installer and Studio's provider), because
|
|
2610
|
-
# guard-runner.sh SOURCES the same file and an unquoted value carrying a space
|
|
2611
|
-
# would execute its own remainder. This reader greps rather than sources, so it
|
|
2612
|
-
# has to undo the quoting itself \u2014 and it must land on the same value the
|
|
2613
|
-
# sourcing consumer gets, or one file has two answers.
|
|
2614
|
-
case $hc_v in
|
|
2615
|
-
'"'*'"') hc_v=\${hc_v#'"'}; hc_v=\${hc_v%'"'} ;;
|
|
2616
|
-
"'"*"'")
|
|
2617
|
-
hc_v=\${hc_v#"'"}; hc_v=\${hc_v%"'"}
|
|
2618
|
-
# \u2026and undo shq's embedded-quote escape, '\\'' -> '. The backslash is matched
|
|
2619
|
-
# through a BRACKET EXPRESSION on purpose. MEASURED, on GNU sed 4.9 (Linux,
|
|
2620
|
-
# the CI platform) and BSD sed (macOS), feeding each the script from a file so
|
|
2621
|
-
# no shell quoting is in the way:
|
|
2622
|
-
#
|
|
2623
|
-
# s/'[\\]''/'/g a'\\''b -> a'b both <- shipped
|
|
2624
|
-
# s/'\\''/'/g a'\\''b -> a'\\''b both <- matches NOTHING, exit 0
|
|
2625
|
-
#
|
|
2626
|
-
# A bare \\' in a BRE is undefined by POSIX, and the obvious pattern therefore
|
|
2627
|
-
# does not fail loudly \u2014 it silently substitutes nothing, on BOTH platforms,
|
|
2628
|
-
# and the token then travels with four stray characters in it. The bracket
|
|
2629
|
-
# expression makes the backslash literal by a construction POSIX does define.
|
|
2630
|
-
hc_v=$(printf '%s' "$hc_v" | sed "s/'[\\]''/'/g") ;;
|
|
2631
|
-
esac
|
|
2632
|
-
HC_VALUE=$hc_v
|
|
2633
|
-
}
|
|
2634
|
-
|
|
2635
|
-
# The scheme and host[:port] of a URL, lowercased \u2014 or NOTHING when the URL holds
|
|
2636
|
-
# a space or a control character anywhere. That refusal is the load-bearing part:
|
|
2637
|
-
# a URL parser drops tabs and newlines before it reads the host, so
|
|
2638
|
-
# "https://ours<newline>@elsewhere/" is a request to "elsewhere", while a
|
|
2639
|
-
# line-by-line reading here would see only "https://ours". The host ends at the
|
|
2640
|
-
# first / ? # or backslash, which is where a URL parser ends it for http(s).
|
|
2641
|
-
halfcycle_origin() {
|
|
2642
|
-
hc_u=$1
|
|
2643
|
-
if [ "$(printf '%s' "$hc_u" | tr -d '[:cntrl:][:space:]')" != "$hc_u" ]; then
|
|
2644
|
-
return 0
|
|
2645
|
-
fi
|
|
2646
|
-
printf '%s\\n' "$hc_u" | sed -n 's|^\\([A-Za-z][A-Za-z0-9+.-]*://[^/?#\\\\]*\\).*$|\\1|p' | tr '[:upper:]' '[:lower:]'
|
|
2647
|
-
}
|
|
2648
|
-
|
|
2649
|
-
# HALFCYCLE_TOKEN in the environment WINS, and it is the CI arm: a job with no
|
|
2650
|
-
# browser and no per-user store exports the credential, and a stale store on a
|
|
2651
|
-
# long-lived runner must not silently win over it. Same order, and the same reason,
|
|
2652
|
-
# as the CLI's own resolver (\`resolve-credential.ts\`). The server it may be sent
|
|
2653
|
-
# to is NOT taken from the environment: it is read from this machine's store in
|
|
2654
|
-
# both arms, so a token supplied this way still goes only where this machine was
|
|
2655
|
-
# set up to send it.
|
|
2656
|
-
# The pin names an id that is not a project id. Re-running the installer against
|
|
2657
|
-
# that pin refuses it too, so the one remedy that works is a fresh pin.
|
|
2658
|
-
halfcycle_bad_id_message() {
|
|
2659
|
-
echo "halfcycle: $PROJECT_ROOT/.halfcycle/bundle.json names an engagement id that is not a Halfcycle project id," >&2
|
|
2660
|
-
echo "halfcycle: so no credential was read. Delete that file and run \\"npx halfcycle\\" again: this repository" >&2
|
|
2661
|
-
echo "halfcycle: is then set up as a new Halfcycle project." >&2
|
|
2662
|
-
}
|
|
2663
|
-
|
|
2664
|
-
TOKEN=\${HALFCYCLE_TOKEN:-}
|
|
2665
|
-
MCP_URL=""
|
|
2666
|
-
|
|
2667
|
-
if [ -n "$TOKEN" ]; then
|
|
2668
|
-
if halfcycle_env_file "$PROJECT_ROOT"; then
|
|
2669
|
-
halfcycle_store_value "$HALFCYCLE_ENV_FILE" HALFCYCLE_MCP_URL
|
|
2670
|
-
MCP_URL=$HC_VALUE
|
|
2671
|
-
fi
|
|
2672
|
-
else
|
|
2673
|
-
if ! halfcycle_env_file "$PROJECT_ROOT"; then
|
|
2674
|
-
case $HALFCYCLE_ENV_PROBLEM in
|
|
2675
|
-
no-pin)
|
|
2676
|
-
echo "halfcycle: no .halfcycle/bundle.json in $PROJECT_ROOT, so this repository has no Halfcycle engagement." >&2
|
|
2677
|
-
echo "halfcycle: run \\"npx halfcycle\\" here to install one." >&2 ;;
|
|
2678
|
-
no-id)
|
|
2679
|
-
echo "halfcycle: $PROJECT_ROOT/.halfcycle/bundle.json names no engagement id." >&2
|
|
2680
|
-
echo "halfcycle: run \\"npx halfcycle\\" here to rewrite it." >&2 ;;
|
|
2681
|
-
bad-id)
|
|
2682
|
-
halfcycle_bad_id_message ;;
|
|
2683
|
-
no-home)
|
|
2684
|
-
echo "halfcycle: HOME is not set, so the credential store cannot be located." >&2 ;;
|
|
2685
|
-
*)
|
|
2686
|
-
# OUTCOME B (T-02b, solo-project-ownership) is a \`no-credential\` PROBLEM
|
|
2687
|
-
# with a REASON attached, checked here rather than as a fifth case value
|
|
2688
|
-
# \u2014 see \`engagementResolutionShell\`'s own docblock for why. Re-running
|
|
2689
|
-
# this installer can never obtain a credential for this reason, so it is
|
|
2690
|
-
# the one sentence in this file that must not say to.
|
|
2691
|
-
if [ "$HALFCYCLE_ENV_NOT_THIS_ACCOUNT" = "1" ]; then
|
|
2692
|
-
echo "halfcycle: this project belongs to another Halfcycle account, so this machine has no" >&2
|
|
2693
|
-
echo "halfcycle: credential for it and the Halfcycle tools are not available here. Nothing is" >&2
|
|
2694
|
-
echo "halfcycle: broken \u2014 ask the project's owner for view access, or sign in as the account" >&2
|
|
2695
|
-
echo "halfcycle: that owns it and run \\"npx halfcycle\\" again." >&2
|
|
2696
|
-
else
|
|
2697
|
-
echo "halfcycle: this machine holds no credential for engagement $HALFCYCLE_ENV_ENGAGEMENT." >&2
|
|
2698
|
-
echo "halfcycle: run \\"npx halfcycle\\" in this repository \u2014 it will sign you in through your browser if needed, and write the credential to $HOME/.halfcycle, outside this repository." >&2
|
|
2699
|
-
fi ;;
|
|
2700
|
-
esac
|
|
2701
|
-
exit 1
|
|
2702
|
-
fi
|
|
2703
|
-
ENV_FILE="$HALFCYCLE_ENV_FILE"
|
|
2704
|
-
|
|
2705
|
-
halfcycle_store_value "$ENV_FILE" HALFCYCLE_TOKEN
|
|
2706
|
-
TOKEN=$HC_VALUE
|
|
2707
|
-
if [ -z "$TOKEN" ]; then
|
|
2708
|
-
echo "halfcycle: HALFCYCLE_TOKEN is absent or empty in $ENV_FILE." >&2
|
|
2709
|
-
echo "halfcycle: run \\"npx halfcycle\\" in this repository to rewrite this machine's credential." >&2
|
|
2710
|
-
exit 1
|
|
2711
|
-
fi
|
|
2712
|
-
halfcycle_store_value "$ENV_FILE" HALFCYCLE_MCP_URL
|
|
2713
|
-
MCP_URL=$HC_VALUE
|
|
2714
|
-
fi
|
|
2715
|
-
|
|
2716
|
-
# THE TOKEN GOES ONLY TO THIS MACHINE'S HALFCYCLE SERVER. .mcp.json is a tracked
|
|
2717
|
-
# file: anyone who can land a commit can change the server's url, and this script
|
|
2718
|
-
# would then hand the credential to whatever it names. Claude Code tells the helper
|
|
2719
|
-
# which url it is about to connect to (CLAUDE_CODE_MCP_SERVER_URL); the server this
|
|
2720
|
-
# machine was set up against is recorded OUTSIDE the repository, beside the
|
|
2721
|
-
# credential. The two must share scheme, host and port, or nothing is printed.
|
|
2722
|
-
# No url at all is a refusal too: sending the credential without knowing where it
|
|
2723
|
-
# is going is the thing this check exists to stop.
|
|
2724
|
-
REQUESTED=\${CLAUDE_CODE_MCP_SERVER_URL:-}
|
|
2725
|
-
if [ -z "$REQUESTED" ]; then
|
|
2726
|
-
echo "halfcycle: Claude Code did not say which server it is connecting to, so the Halfcycle credential was not sent." >&2
|
|
2727
|
-
echo "halfcycle: update Claude Code, then reconnect." >&2
|
|
2728
|
-
exit 1
|
|
2729
|
-
fi
|
|
2730
|
-
if [ -z "$MCP_URL" ] && [ "\${HALFCYCLE_ENV_PROBLEM:-}" = "bad-id" ]; then
|
|
2731
|
-
halfcycle_bad_id_message
|
|
2732
|
-
exit 1
|
|
2733
|
-
fi
|
|
2734
|
-
if [ -z "$MCP_URL" ]; then
|
|
2735
|
-
echo "halfcycle: this machine has no Halfcycle server address recorded for this repository, so the credential was not sent." >&2
|
|
2736
|
-
echo "halfcycle: run \\"npx halfcycle\\" in this repository on this machine to record it." >&2
|
|
2737
|
-
exit 1
|
|
2738
|
-
fi
|
|
2739
|
-
WANT=$(halfcycle_origin "$MCP_URL")
|
|
2740
|
-
GOT=$(halfcycle_origin "$REQUESTED")
|
|
2741
|
-
if [ -z "$WANT" ] || [ "$GOT" != "$WANT" ]; then
|
|
2742
|
-
SHOWN=$(printf '%s' "$REQUESTED" | tr -cd '[:graph:]' | cut -c1-200)
|
|
2743
|
-
echo "halfcycle: .mcp.json asks for this repository's Halfcycle credential to be sent to $SHOWN," >&2
|
|
2744
|
-
echo "halfcycle: which is not this machine's Halfcycle server (\${WANT:-none recorded}). The credential was NOT sent." >&2
|
|
2745
|
-
echo "halfcycle: if nobody meant to change .mcp.json, treat that change as suspect; \\"npx halfcycle\\" restores the entry." >&2
|
|
2746
|
-
exit 1
|
|
2747
|
-
fi
|
|
2748
|
-
|
|
2749
|
-
# JSON-escape: backslash first, then double quote. A token carrying either would
|
|
2750
|
-
# otherwise emit invalid JSON, and invalid JSON is dropped SILENTLY \u2014 the
|
|
2751
|
-
# connection succeeds carrying no Authorization header at all.
|
|
2752
|
-
ESCAPED=$(printf '%s' "$TOKEN" | sed -e 's/\\\\/\\\\\\\\/g' -e 's/"/\\\\"/g')
|
|
2753
|
-
|
|
2754
|
-
printf '{"Authorization":"Bearer %s"}\\n' "$ESCAPED"
|
|
2755
|
-
`;
|
|
2756
|
-
}
|
|
2757
2620
|
function generateMcpRegistration(existing, mcpOrigin) {
|
|
2758
2621
|
let base = { mcpServers: {} };
|
|
2759
2622
|
if (existing !== null) {
|
|
@@ -2820,6 +2683,42 @@ ${header}
|
|
|
2820
2683
|
return "failed";
|
|
2821
2684
|
}
|
|
2822
2685
|
}
|
|
2686
|
+
var GITATTRIBUTES_HEADER = "# Halfcycle \u2014 generated scripts keep LF line endings";
|
|
2687
|
+
function readTextOrNull(path) {
|
|
2688
|
+
try {
|
|
2689
|
+
return readFileSync5(path, "utf-8");
|
|
2690
|
+
} catch {
|
|
2691
|
+
return null;
|
|
2692
|
+
}
|
|
2693
|
+
}
|
|
2694
|
+
function lfRules(paths) {
|
|
2695
|
+
return [...paths].filter((path) => path.endsWith(".sh")).map((path) => `${path} text eol=lf`);
|
|
2696
|
+
}
|
|
2697
|
+
var REQUIRED_GITATTRIBUTES_RULES = lfRules(Object.keys(OWNED_GENERATED_HEADERS));
|
|
2698
|
+
function reconcileGitattributes(targetRepo) {
|
|
2699
|
+
const path = join5(targetRepo, GITATTRIBUTES_REL);
|
|
2700
|
+
const rules = REQUIRED_GITATTRIBUTES_RULES;
|
|
2701
|
+
const routed = [];
|
|
2702
|
+
try {
|
|
2703
|
+
if (lstatOrNull(path)?.isSymbolicLink() === true)
|
|
2704
|
+
return "failed";
|
|
2705
|
+
if (!existsSync3(path)) {
|
|
2706
|
+
writeAllowlisted(path, targetRepo, [GITATTRIBUTES_HEADER, ...rules].join("\n") + "\n", routed);
|
|
2707
|
+
return "written";
|
|
2708
|
+
}
|
|
2709
|
+
const existing = readFileSync5(path, "utf-8");
|
|
2710
|
+
const present = new Set(existing.split("\n").map((l) => l.trim()));
|
|
2711
|
+
const toAdd = rules.filter((rule) => !present.has(rule));
|
|
2712
|
+
if (toAdd.length === 0)
|
|
2713
|
+
return "skipped";
|
|
2714
|
+
const separator = existing === "" ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
|
|
2715
|
+
const suffix = separator + [GITATTRIBUTES_HEADER, ...toAdd].join("\n") + "\n";
|
|
2716
|
+
writeAllowlisted(path, targetRepo, existing + suffix, routed);
|
|
2717
|
+
return "written";
|
|
2718
|
+
} catch {
|
|
2719
|
+
return "failed";
|
|
2720
|
+
}
|
|
2721
|
+
}
|
|
2823
2722
|
function previousPinForEngagement(targetRepoRoot, engagementId) {
|
|
2824
2723
|
try {
|
|
2825
2724
|
const existing = readBundlePin(targetRepoRoot);
|
|
@@ -3070,9 +2969,6 @@ async function install(options) {
|
|
|
3070
2969
|
break;
|
|
3071
2970
|
}
|
|
3072
2971
|
if (credential) {
|
|
3073
|
-
const helperPath = join5(targetRepo, MCP_HEADERS_HELPER_REL);
|
|
3074
|
-
const helperOutcome = recordOwnedGenerated(report, helperPath, targetRepo, generateMcpHeadersHelper(), OWNED_GENERATED_HEADERS[MCP_HEADERS_HELPER_REL], MCP_HEADERS_HELPER_REL);
|
|
3075
|
-
noteFile(draft, targetRepo, MCP_HEADERS_HELPER_REL, helperOutcome);
|
|
3076
2972
|
const mcpPath = join5(targetRepo, MCP_REGISTRATION_REL);
|
|
3077
2973
|
const existingMcp = existsSync3(mcpPath) ? readFileSync5(mcpPath, "utf-8") : null;
|
|
3078
2974
|
const mcpContent = generateMcpRegistration(existingMcp, credential.mcpUrl);
|
|
@@ -3084,6 +2980,24 @@ async function install(options) {
|
|
|
3084
2980
|
} else {
|
|
3085
2981
|
report.skippedPaths.push(MCP_REGISTRATION_REL);
|
|
3086
2982
|
}
|
|
2983
|
+
const helperRel = RETIRED_MCP_HEADERS_HELPER_REL;
|
|
2984
|
+
switch (removeRetiredMcpHeadersHelper(targetRepo)) {
|
|
2985
|
+
case "removed":
|
|
2986
|
+
recordRemovedFile(draft, helperRel);
|
|
2987
|
+
report.writtenPaths.push(`${helperRel} (REMOVED \u2014 Halfcycle no longer uses this file)`);
|
|
2988
|
+
break;
|
|
2989
|
+
case "kept-foreign":
|
|
2990
|
+
report.skippedPaths.push(`${helperRel} (KEPT \u2014 Halfcycle no longer uses this file, and it is not one Halfcycle generated byte for byte, so it was left alone; delete it if nothing of yours needs it)`);
|
|
2991
|
+
break;
|
|
2992
|
+
case "kept-uncommitted":
|
|
2993
|
+
report.skippedPaths.push(`${helperRel} (KEPT \u2014 Halfcycle no longer uses this file, but it is not a clean, committed copy in this repository, so it was left alone rather than risk losing an edit; delete it when you are ready)`);
|
|
2994
|
+
break;
|
|
2995
|
+
case "failed":
|
|
2996
|
+
report.skippedPaths.push(`${helperRel} (could not be removed \u2014 Halfcycle no longer uses this file, DELETE IT BY HAND)`);
|
|
2997
|
+
break;
|
|
2998
|
+
case "absent":
|
|
2999
|
+
break;
|
|
3000
|
+
}
|
|
3087
3001
|
} else {
|
|
3088
3002
|
report.skippedPaths.push(`${MCP_REGISTRATION_REL} (no MCP origin \u2014 no credential supplied)`);
|
|
3089
3003
|
const mcpPath = join5(targetRepo, MCP_REGISTRATION_REL);
|
|
@@ -3099,6 +3013,15 @@ async function install(options) {
|
|
|
3099
3013
|
} else {
|
|
3100
3014
|
record(report, gitignoreOutcome, ".gitignore");
|
|
3101
3015
|
}
|
|
3016
|
+
const gitattributesPath = join5(targetRepo, GITATTRIBUTES_REL);
|
|
3017
|
+
const gitattributesBefore = readTextOrNull(gitattributesPath);
|
|
3018
|
+
const gitattributesOutcome = reconcileGitattributes(targetRepo);
|
|
3019
|
+
recordGitattributesReconcile(draft, gitattributesBefore, readTextOrNull(gitattributesPath));
|
|
3020
|
+
if (gitattributesOutcome === "failed") {
|
|
3021
|
+
report.skippedPaths.push(`${GITATTRIBUTES_REL} (write failed \u2014 a clone made with core.autocrlf=true may check the hook scripts out with CRLF line endings)`);
|
|
3022
|
+
} else {
|
|
3023
|
+
record(report, gitattributesOutcome, GITATTRIBUTES_REL);
|
|
3024
|
+
}
|
|
3102
3025
|
const identityOutcome = writeProjectIdentity(targetRepo);
|
|
3103
3026
|
record(report, identityOutcome, PROJECT_IDENTITY_REL);
|
|
3104
3027
|
noteFile(draft, targetRepo, PROJECT_IDENTITY_REL, identityOutcome);
|
|
@@ -4214,7 +4137,7 @@ function armScript(credential, act, label, plainLabel, home) {
|
|
|
4214
4137
|
}
|
|
4215
4138
|
function identitySwitchRemedy(home, interactive) {
|
|
4216
4139
|
const setOrRemove = `set HALFCYCLE_TOKEN for that account, or remove ${accountStorePath(home)}`;
|
|
4217
|
-
return interactive ? `To act as a different account: run this again and
|
|
4140
|
+
return interactive ? `To act as a different account: run this again and ${SWITCH_ACCOUNT_ANSWER}, ${setOrRemove} to be asked for a sign-in next time.` : `To act as a different account: ${setOrRemove} and run this again with a terminal to sign in.`;
|
|
4218
4141
|
}
|
|
4219
4142
|
async function confirmActingIdentity(serviceUrl, credential, act, deps = {}) {
|
|
4220
4143
|
const write = deps.write ?? ((text) => process.stdout.write(text));
|
|
@@ -4384,7 +4307,7 @@ async function mintBoardEnterCode(baseUrl, sessionToken) {
|
|
|
4384
4307
|
|
|
4385
4308
|
// dist/probe-mcp.js
|
|
4386
4309
|
var PROBE_TIMEOUT_MS = 5e3;
|
|
4387
|
-
async function probeMcpOrigin(mcpUrl) {
|
|
4310
|
+
async function probeMcpOrigin(mcpUrl, token) {
|
|
4388
4311
|
const url = mcpEndpointUrl(mcpUrl);
|
|
4389
4312
|
const controller = new AbortController();
|
|
4390
4313
|
const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
|
|
@@ -4392,32 +4315,71 @@ async function probeMcpOrigin(mcpUrl) {
|
|
|
4392
4315
|
try {
|
|
4393
4316
|
res = await fetch(url, {
|
|
4394
4317
|
method: "POST",
|
|
4395
|
-
headers: { "content-type": "application/json" },
|
|
4318
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
|
|
4396
4319
|
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }),
|
|
4320
|
+
redirect: "manual",
|
|
4397
4321
|
signal: controller.signal
|
|
4398
4322
|
});
|
|
4399
4323
|
} catch (err) {
|
|
4400
4324
|
return {
|
|
4401
4325
|
reached: false,
|
|
4326
|
+
credentialRefused: false,
|
|
4402
4327
|
problem: controller.signal.aborted ? `it did not answer within ${PROBE_TIMEOUT_MS / 1e3} seconds` : `it could not be reached (${err instanceof Error ? err.message : String(err)})`
|
|
4403
4328
|
};
|
|
4404
4329
|
} finally {
|
|
4405
4330
|
clearTimeout(timer);
|
|
4406
4331
|
}
|
|
4407
4332
|
if (res.status === 404) {
|
|
4408
|
-
return { reached: false, problem: "that address answered, but it serves no /mcp endpoint" };
|
|
4333
|
+
return { reached: false, credentialRefused: false, problem: "that address answered, but it serves no /mcp endpoint" };
|
|
4334
|
+
}
|
|
4335
|
+
if (res.status === 401) {
|
|
4336
|
+
return {
|
|
4337
|
+
reached: false,
|
|
4338
|
+
credentialRefused: true,
|
|
4339
|
+
problem: "it answered, but refused this machine's Halfcycle credential"
|
|
4340
|
+
};
|
|
4341
|
+
}
|
|
4342
|
+
if (res.status >= 300 && res.status < 400) {
|
|
4343
|
+
return {
|
|
4344
|
+
reached: false,
|
|
4345
|
+
credentialRefused: false,
|
|
4346
|
+
problem: `it redirected elsewhere (${res.status}), which is not followed, so the credential was not sent on`
|
|
4347
|
+
};
|
|
4409
4348
|
}
|
|
4410
4349
|
if (!res.ok) {
|
|
4411
|
-
return { reached: false, problem: `it answered ${res.status}` };
|
|
4350
|
+
return { reached: false, credentialRefused: false, problem: `it answered ${res.status}` };
|
|
4412
4351
|
}
|
|
4413
4352
|
const body = await res.json().catch(() => null);
|
|
4414
4353
|
const name = body?.result?.serverInfo?.name;
|
|
4415
4354
|
if (typeof name !== "string" || name === "") {
|
|
4416
|
-
return { reached: false, problem: "it answered, but not as an MCP server" };
|
|
4355
|
+
return { reached: false, credentialRefused: false, problem: "it answered, but not as an MCP server" };
|
|
4417
4356
|
}
|
|
4418
4357
|
return { reached: true, serverName: name };
|
|
4419
4358
|
}
|
|
4420
4359
|
|
|
4360
|
+
// dist/claude-version-probe.js
|
|
4361
|
+
import { execFileSync as execFileSync3 } from "node:child_process";
|
|
4362
|
+
function probeClaudeCodeVersion(timeoutMs, deps = {}) {
|
|
4363
|
+
const platform4 = deps.platform ?? process.platform;
|
|
4364
|
+
const exec = deps.exec ?? execFileSync3;
|
|
4365
|
+
let raw;
|
|
4366
|
+
try {
|
|
4367
|
+
raw = platform4 === "win32" ? exec("cmd.exe", ["/d", "/s", "/c", '"claude --version"'], {
|
|
4368
|
+
encoding: "utf-8",
|
|
4369
|
+
timeout: timeoutMs,
|
|
4370
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
4371
|
+
windowsVerbatimArguments: true
|
|
4372
|
+
}) : exec("claude", ["--version"], {
|
|
4373
|
+
encoding: "utf-8",
|
|
4374
|
+
timeout: timeoutMs,
|
|
4375
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
4376
|
+
});
|
|
4377
|
+
} catch {
|
|
4378
|
+
return void 0;
|
|
4379
|
+
}
|
|
4380
|
+
return /(\d+\.\d+\.\d+)/.exec(raw)?.[1];
|
|
4381
|
+
}
|
|
4382
|
+
|
|
4421
4383
|
// dist/build-record/write.js
|
|
4422
4384
|
import { closeSync, existsSync as existsSync4, mkdirSync as mkdirSync5, openSync, readFileSync as readFileSync6, renameSync, rmSync as rmSync2, writeSync } from "node:fs";
|
|
4423
4385
|
import { basename as basename2, join as join6, resolve as resolve2, relative as relative2, isAbsolute as isAbsolute2 } from "node:path";
|
|
@@ -4993,7 +4955,7 @@ async function openPhase(credential, phase, entry, home) {
|
|
|
4993
4955
|
}
|
|
4994
4956
|
|
|
4995
4957
|
// dist/close-phase.js
|
|
4996
|
-
import { execFileSync as
|
|
4958
|
+
import { execFileSync as execFileSync4 } from "node:child_process";
|
|
4997
4959
|
var NO_STATEMENTS = { thesis: null, outcome: null, deviations: [] };
|
|
4998
4960
|
function readCloseCommit(repoRoot) {
|
|
4999
4961
|
if (repoRoot === void 0) {
|
|
@@ -5004,7 +4966,7 @@ function readCloseCommit(repoRoot) {
|
|
|
5004
4966
|
}
|
|
5005
4967
|
let out;
|
|
5006
4968
|
try {
|
|
5007
|
-
out =
|
|
4969
|
+
out = execFileSync4("git", ["-C", repoRoot, "rev-parse", "HEAD"], {
|
|
5008
4970
|
encoding: "utf-8",
|
|
5009
4971
|
env: gitEnvForNamedRepo(),
|
|
5010
4972
|
stdio: ["ignore", "pipe", "ignore"]
|
|
@@ -5299,7 +5261,7 @@ function validate(options) {
|
|
|
5299
5261
|
if (lexists(pinPath)) {
|
|
5300
5262
|
try {
|
|
5301
5263
|
const pin = JSON.parse(readFileSync8(pinPath, "utf-8"));
|
|
5302
|
-
const id = isPlainObject2(pin) ? pin[
|
|
5264
|
+
const id = isPlainObject2(pin) ? pin[PIN_ENGAGEMENT_ID_FIELD] : void 0;
|
|
5303
5265
|
engagementId = typeof id === "string" && id !== "" ? id : void 0;
|
|
5304
5266
|
} catch {
|
|
5305
5267
|
pinReadable = false;
|
|
@@ -5441,17 +5403,33 @@ function unmergeMcp(value, rec) {
|
|
|
5441
5403
|
}
|
|
5442
5404
|
return { changed, keptEntry };
|
|
5443
5405
|
}
|
|
5444
|
-
function
|
|
5406
|
+
function gitignoreRules(targetRepo, writeSet) {
|
|
5407
|
+
return {
|
|
5408
|
+
rel: GITIGNORE_REL,
|
|
5409
|
+
header: GITIGNORE_HEADER,
|
|
5410
|
+
ourLines: writeSet.gitignoreLines,
|
|
5411
|
+
keepLine: (line) => coveredPathExists(targetRepo, line)
|
|
5412
|
+
};
|
|
5413
|
+
}
|
|
5414
|
+
function gitattributesRules(writeSet) {
|
|
5415
|
+
return {
|
|
5416
|
+
rel: GITATTRIBUTES_REL,
|
|
5417
|
+
header: GITATTRIBUTES_HEADER,
|
|
5418
|
+
ourLines: writeSet.gitattributesLines,
|
|
5419
|
+
keepLine: () => false
|
|
5420
|
+
};
|
|
5421
|
+
}
|
|
5422
|
+
function trimAppendedBlocks(text, rec, rules) {
|
|
5445
5423
|
const keptLines = [];
|
|
5446
5424
|
const foreignLines = [];
|
|
5447
5425
|
let current = text;
|
|
5448
5426
|
for (const block of [...rec.appended].reverse()) {
|
|
5449
5427
|
const lines = block.split("\n").filter((line) => line !== "");
|
|
5450
|
-
const foreign = lines.filter((line) => !
|
|
5428
|
+
const foreign = lines.filter((line) => !rules.ourLines.has(line));
|
|
5451
5429
|
foreignLines.push(...foreign);
|
|
5452
|
-
const ours = lines.filter((line) =>
|
|
5453
|
-
const entries = ours.filter((line) => line !==
|
|
5454
|
-
const kept = entries.filter((line) =>
|
|
5430
|
+
const ours = lines.filter((line) => rules.ourLines.has(line));
|
|
5431
|
+
const entries = ours.filter((line) => line !== rules.header);
|
|
5432
|
+
const kept = entries.filter((line) => rules.keepLine(line));
|
|
5455
5433
|
keptLines.push(...kept);
|
|
5456
5434
|
if (kept.length === 0 && foreign.length === 0 && current.includes(block)) {
|
|
5457
5435
|
const at = current.lastIndexOf(block);
|
|
@@ -5459,13 +5437,13 @@ function trimGitignore(targetRepo, text, rec, writeSet) {
|
|
|
5459
5437
|
continue;
|
|
5460
5438
|
}
|
|
5461
5439
|
const separated = block.startsWith("\n") || block.includes("\n\n");
|
|
5462
|
-
const removable = ours.filter((line) => line ===
|
|
5440
|
+
const removable = ours.filter((line) => line === rules.header ? kept.length === 0 && foreign.length === 0 : !kept.includes(line));
|
|
5463
5441
|
for (const line of removable) {
|
|
5464
5442
|
const fileLines = current.split("\n");
|
|
5465
5443
|
const at = fileLines.lastIndexOf(line);
|
|
5466
5444
|
if (at < 0)
|
|
5467
5445
|
continue;
|
|
5468
|
-
const withSeparator = line ===
|
|
5446
|
+
const withSeparator = line === rules.header && separated && at > 0 && fileLines[at - 1] === "";
|
|
5469
5447
|
fileLines.splice(withSeparator ? at - 1 : at, withSeparator ? 2 : 1);
|
|
5470
5448
|
current = fileLines.join("\n");
|
|
5471
5449
|
}
|
|
@@ -5557,7 +5535,13 @@ function uninstall(options) {
|
|
|
5557
5535
|
continue;
|
|
5558
5536
|
removeEmptyCreatedDir(targetRepo, dir, writeSet, result, name);
|
|
5559
5537
|
}
|
|
5560
|
-
|
|
5538
|
+
const keptIgnoreLines = trimAppendedFile(targetRepo, manifest.gitignore, gitignoreRules(targetRepo, writeSet), result, name);
|
|
5539
|
+
for (const line of keptIgnoreLines) {
|
|
5540
|
+
result.keptIgnoreLines.push(`${line.trim()} \u2014 ${line.trim().replace(/^\//, "")} still exists`);
|
|
5541
|
+
}
|
|
5542
|
+
if (manifest.gitattributes !== void 0) {
|
|
5543
|
+
trimAppendedFile(targetRepo, manifest.gitattributes, gitattributesRules(writeSet), result, name);
|
|
5544
|
+
}
|
|
5561
5545
|
const credential = handleCredential(options, checked);
|
|
5562
5546
|
const credentialFailed = credential.kind === "failed";
|
|
5563
5547
|
if (result.failed.length === 0 && !credentialFailed) {
|
|
@@ -5671,40 +5655,39 @@ function removeEmptyCreatedDir(targetRepo, dir, writeSet, lists, name) {
|
|
|
5671
5655
|
name(lists.failed, `${dir}/`, dir);
|
|
5672
5656
|
}
|
|
5673
5657
|
}
|
|
5674
|
-
function
|
|
5658
|
+
function trimAppendedFile(targetRepo, rec, rules, lists, name) {
|
|
5659
|
+
const { rel } = rules;
|
|
5675
5660
|
if (rec.appended.length === 0)
|
|
5676
|
-
return;
|
|
5677
|
-
if (symlinkOnPath(targetRepo,
|
|
5678
|
-
name(lists.leftAlone, `${
|
|
5679
|
-
return;
|
|
5661
|
+
return [];
|
|
5662
|
+
if (symlinkOnPath(targetRepo, rel)) {
|
|
5663
|
+
name(lists.leftAlone, `${rel} (a symbolic link \u2014 not followed)`, rel);
|
|
5664
|
+
return [];
|
|
5680
5665
|
}
|
|
5681
|
-
const path = join8(targetRepo,
|
|
5666
|
+
const path = join8(targetRepo, rel);
|
|
5682
5667
|
let before;
|
|
5683
5668
|
try {
|
|
5684
5669
|
before = readFileSync8(path, "utf-8");
|
|
5685
5670
|
} catch {
|
|
5686
|
-
return;
|
|
5671
|
+
return [];
|
|
5687
5672
|
}
|
|
5688
|
-
const { text, keptLines, foreignLines } =
|
|
5673
|
+
const { text, keptLines, foreignLines } = trimAppendedBlocks(before, rec, rules);
|
|
5689
5674
|
for (const line of foreignLines) {
|
|
5690
|
-
name(lists.leftAlone, `${
|
|
5691
|
-
}
|
|
5692
|
-
for (const line of keptLines) {
|
|
5693
|
-
lists.keptIgnoreLines.push(`${line.trim()} \u2014 ${line.trim().replace(/^\//, "")} still exists`);
|
|
5675
|
+
name(lists.leftAlone, `${rel}: the line ${JSON.stringify(line)} (Halfcycle never writes it, so it was kept)`);
|
|
5694
5676
|
}
|
|
5695
5677
|
if (text === before)
|
|
5696
|
-
return;
|
|
5678
|
+
return keptLines;
|
|
5697
5679
|
try {
|
|
5698
5680
|
if (text === "" && rec.created) {
|
|
5699
5681
|
rmSync3(path);
|
|
5700
|
-
name(lists.removed,
|
|
5682
|
+
name(lists.removed, rel, rel);
|
|
5701
5683
|
} else {
|
|
5702
5684
|
writeFileSync5(path, text, "utf-8");
|
|
5703
|
-
name(lists.restored,
|
|
5685
|
+
name(lists.restored, rel, rel);
|
|
5704
5686
|
}
|
|
5705
5687
|
} catch {
|
|
5706
|
-
name(lists.failed,
|
|
5688
|
+
name(lists.failed, rel, rel);
|
|
5707
5689
|
}
|
|
5690
|
+
return keptLines;
|
|
5708
5691
|
}
|
|
5709
5692
|
function handleCredential(options, checked) {
|
|
5710
5693
|
const { removeCredential, home } = options;
|
|
@@ -5820,7 +5803,6 @@ function renderUninstallReport(result, home) {
|
|
|
5820
5803
|
}
|
|
5821
5804
|
|
|
5822
5805
|
// dist/banner-facts.js
|
|
5823
|
-
import { execFileSync as execFileSync4 } from "node:child_process";
|
|
5824
5806
|
import { readFileSync as readFileSync9 } from "node:fs";
|
|
5825
5807
|
import { basename as basename3, join as join9, resolve as resolve3 } from "node:path";
|
|
5826
5808
|
var BRAND_URL = "halfcycle.ai";
|
|
@@ -5841,17 +5823,8 @@ function bundleVersion() {
|
|
|
5841
5823
|
return void 0;
|
|
5842
5824
|
}
|
|
5843
5825
|
}
|
|
5844
|
-
function claudeCodeVersion() {
|
|
5845
|
-
|
|
5846
|
-
const raw = execFileSync4("claude", ["--version"], {
|
|
5847
|
-
encoding: "utf-8",
|
|
5848
|
-
timeout: 3e3,
|
|
5849
|
-
stdio: ["ignore", "pipe", "ignore"]
|
|
5850
|
-
});
|
|
5851
|
-
return /(\d+\.\d+\.\d+)/.exec(raw)?.[1];
|
|
5852
|
-
} catch {
|
|
5853
|
-
return void 0;
|
|
5854
|
-
}
|
|
5826
|
+
function claudeCodeVersion(deps = {}) {
|
|
5827
|
+
return probeClaudeCodeVersion(3e3, deps);
|
|
5855
5828
|
}
|
|
5856
5829
|
var REAL_PROBES = {
|
|
5857
5830
|
bundleVersion,
|
|
@@ -5952,21 +5925,10 @@ function compareVersions(a, b) {
|
|
|
5952
5925
|
}
|
|
5953
5926
|
return 0;
|
|
5954
5927
|
}
|
|
5955
|
-
function reportClaudeCodeVersion(verbose2) {
|
|
5956
|
-
|
|
5957
|
-
|
|
5958
|
-
raw = execFileSync5("claude", ["--version"], {
|
|
5959
|
-
encoding: "utf-8",
|
|
5960
|
-
timeout: 5e3,
|
|
5961
|
-
stdio: ["ignore", "pipe", "ignore"]
|
|
5962
|
-
});
|
|
5963
|
-
} catch {
|
|
5928
|
+
function reportClaudeCodeVersion(verbose2, deps = {}) {
|
|
5929
|
+
const version = probeClaudeCodeVersion(5e3, deps);
|
|
5930
|
+
if (version === void 0)
|
|
5964
5931
|
return;
|
|
5965
|
-
}
|
|
5966
|
-
const found = /(\d+\.\d+\.\d+)/.exec(raw);
|
|
5967
|
-
if (!found)
|
|
5968
|
-
return;
|
|
5969
|
-
const version = found[1];
|
|
5970
5932
|
if (compareVersions(version, MIN_HEADERS_HELPER_VERSION) >= 0) {
|
|
5971
5933
|
if (verbose2) {
|
|
5972
5934
|
process.stdout.write(`[halfcycle] Claude Code ${version} detected \u2014 new enough for the credential helper.
|
|
@@ -6008,6 +5970,15 @@ function isHalfcycleMonorepo(dir) {
|
|
|
6008
5970
|
return false;
|
|
6009
5971
|
}
|
|
6010
5972
|
}
|
|
5973
|
+
function isHomeFolder(dir) {
|
|
5974
|
+
try {
|
|
5975
|
+
const target = realpathSync(dir);
|
|
5976
|
+
const home = realpathSync(homedir3());
|
|
5977
|
+
return process.platform === "win32" ? target.toLowerCase() === home.toLowerCase() : target === home;
|
|
5978
|
+
} catch {
|
|
5979
|
+
return false;
|
|
5980
|
+
}
|
|
5981
|
+
}
|
|
6011
5982
|
var RETIRED_VERBS = /* @__PURE__ */ new Map([
|
|
6012
5983
|
["ci", '"ci" was removed \u2014 CI setup is part of your project, not Halfcycle']
|
|
6013
5984
|
]);
|
|
@@ -6047,6 +6018,12 @@ ${USAGE}`);
|
|
|
6047
6018
|
product's own source tree \u2014 installing here would write over tracked files.
|
|
6048
6019
|
To see what this CLI does: halfcycle --help
|
|
6049
6020
|
To install into a real project: halfcycle <path-to-that-repo>
|
|
6021
|
+
`);
|
|
6022
|
+
process.exit(1);
|
|
6023
|
+
return;
|
|
6024
|
+
}
|
|
6025
|
+
if (isHomeFolder(targetRepo)) {
|
|
6026
|
+
process.stderr.write(`halfcycle: refusing to set up in your home folder (${targetRepo}). Run "npx halfcycle" inside a project folder.
|
|
6050
6027
|
`);
|
|
6051
6028
|
process.exit(1);
|
|
6052
6029
|
return;
|
|
@@ -6189,12 +6166,16 @@ ${USAGE}`);
|
|
|
6189
6166
|
`);
|
|
6190
6167
|
}
|
|
6191
6168
|
if (credential) {
|
|
6192
|
-
const probe = await probeMcpOrigin(credential.mcpUrl);
|
|
6169
|
+
const probe = await probeMcpOrigin(credential.mcpUrl, credential.token);
|
|
6193
6170
|
if (probe.reached) {
|
|
6194
6171
|
if (verbose) {
|
|
6195
6172
|
process.stdout.write(`[halfcycle] Method delivery (MCP): ${credential.mcpUrl} \u2014 reachable (${probe.serverName})
|
|
6196
6173
|
`);
|
|
6197
6174
|
}
|
|
6175
|
+
} else if (probe.credentialRefused) {
|
|
6176
|
+
process.stdout.write(`[halfcycle] Method delivery (MCP): ${credential.mcpUrl} \u2014 ${probe.problem}.
|
|
6177
|
+
[halfcycle] The address is right, but the Halfcycle commands will have no method context until it accepts this machine's credential. Re-run "npx halfcycle" in this folder; if it says this again, the refusal is on Halfcycle's side, not in this project.
|
|
6178
|
+
`);
|
|
6198
6179
|
} else {
|
|
6199
6180
|
process.stdout.write(`[halfcycle] Method delivery (MCP): ${credential.mcpUrl} \u2014 NOT reachable: ${probe.problem}.
|
|
6200
6181
|
[halfcycle] The Halfcycle commands will have no method context until that address answers. This is the SECOND of two origins and the platform supplied it, so it is not the CONTROL origin (${credential.serviceUrl}) that is wrong \u2014 that one just worked. It is recorded as HALFCYCLE_MCP_URL in this engagement's credential store; re-run this installer once the service is up.
|
|
@@ -6204,11 +6185,11 @@ ${USAGE}`);
|
|
|
6204
6185
|
if (credential && verbose) {
|
|
6205
6186
|
process.stdout.write(`[halfcycle] Guard evaluation: ON \u2014 the hook evaluates against ${credential.guardUrl}. Credentials (${GUARD_COVERAGE_REQUIRED_KEYS.join(", ")}) are in ${engagementEnvPath(engagementId)}, outside this repository; the hook loads them itself, so you do not export anything and nothing here can be committed.
|
|
6206
6187
|
`);
|
|
6207
|
-
process.stdout.write(`[halfcycle] Your credential is at ${engagementEnvPath(engagementId)} \u2014 outside this repository, readable only by you. .mcp.json reads it at connection time through
|
|
6188
|
+
process.stdout.write(`[halfcycle] Your credential is at ${engagementEnvPath(engagementId)} \u2014 outside this repository, readable only by you. .mcp.json reads it at connection time through the Halfcycle runner in ${VENDORED_BIN_REL}, so there is no token in this tree to commit and nothing for you to export.
|
|
6208
6189
|
`);
|
|
6209
|
-
process.stdout.write(`[halfcycle] Expect a workspace trust prompt the first time you open this folder \u2014 the
|
|
6190
|
+
process.stdout.write(`[halfcycle] Expect a workspace trust prompt the first time you open this folder \u2014 the connection runs a command from this project, and Claude Code will not run it until you accept. Needs Claude Code ${MIN_HEADERS_HELPER_VERSION} or newer (${MIN_HEADER_ROTATION_VERSION}+ to re-authenticate a rotated token without restarting).
|
|
6210
6191
|
`);
|
|
6211
|
-
process.stdout.write(`[halfcycle] If Halfcycle
|
|
6192
|
+
process.stdout.write(`[halfcycle] If Halfcycle reads as not connected, or every call returns 401, the connection could not read GUARD_SERVICE_TOKEN from ${engagementEnvPath(engagementId)} \u2014 re-run this installer.
|
|
6212
6193
|
`);
|
|
6213
6194
|
}
|
|
6214
6195
|
if (credential === void 0 && notThisAccountMessage !== void 0) {
|