halfcycle 0.3.35 → 0.3.37
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 +16 -1
- package/bin/bin.bundle.mjs +471 -61
- package/dist/banner-facts.d.ts +6 -2
- package/dist/banner-facts.d.ts.map +1 -1
- package/dist/bin.js +435 -430
- 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/device-signin.d.ts +93 -17
- package/dist/device-signin.d.ts.map +1 -1
- package/dist/engagement-credential.d.ts +39 -27
- package/dist/engagement-credential.d.ts.map +1 -1
- package/dist/index.js +189 -290
- package/dist/index.js.map +3 -3
- package/dist/install-manifest.d.ts +32 -4
- package/dist/install-manifest.d.ts.map +1 -1
- package/dist/install.d.ts +53 -23
- package/dist/install.d.ts.map +1 -1
- package/dist/loopback-signin.d.ts +8 -2
- package/dist/loopback-signin.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) {
|
|
@@ -228,6 +230,10 @@ function looksLikeSecret(text) {
|
|
|
228
230
|
return false;
|
|
229
231
|
}
|
|
230
232
|
|
|
233
|
+
// ../core/dist/mcp-registration.js
|
|
234
|
+
var MCP_REGISTRATION_REL = ".mcp.json";
|
|
235
|
+
var MCP_SERVER_KEY = "halfcycle";
|
|
236
|
+
|
|
231
237
|
// ../events/dist/result.js
|
|
232
238
|
import { z as z2 } from "zod";
|
|
233
239
|
var firedGuardSchema = z2.object({
|
|
@@ -1218,6 +1224,19 @@ var ciTokenExchangeResponseSchema = z10.object({
|
|
|
1218
1224
|
expiresAt: utcIso8601,
|
|
1219
1225
|
engagementId: z10.string()
|
|
1220
1226
|
}).strict();
|
|
1227
|
+
var NO_CREDENTIAL_PLACEHOLDER = "halfcycle:no-credential";
|
|
1228
|
+
var NOT_THIS_ACCOUNT_PLACEHOLDER = "halfcycle:not-this-account";
|
|
1229
|
+
var UPDATE_CLAUDE_CODE_PLACEHOLDER = "halfcycle:update-claude-code";
|
|
1230
|
+
var SWITCH_ACCOUNT_ANSWER = 'answer "s" when asked';
|
|
1231
|
+
var NO_CREDENTIAL_SENTENCE = 'Halfcycle is not connected: this machine has no working Halfcycle credential for this project. Run "npx halfcycle" in the project folder to sign in again.';
|
|
1232
|
+
var NOT_THIS_ACCOUNT_SENTENCE = `Halfcycle is not connected: this project belongs to another Halfcycle account. To switch, run "npx halfcycle" in this project and ${SWITCH_ACCOUNT_ANSWER}.`;
|
|
1233
|
+
var UPDATE_CLAUDE_CODE_SENTENCE = "Halfcycle is not connected: this Claude Code version does not name the server it connects to, so no credential was sent. Update Claude Code, then reopen.";
|
|
1234
|
+
var NO_NODE_SENTENCE = "Halfcycle is not connected: Node is not installed, or not on the path this session uses, so nothing here is checked. Install Node, then reopen.";
|
|
1235
|
+
var PLACEHOLDER_SENTENCES = Object.freeze({
|
|
1236
|
+
[NO_CREDENTIAL_PLACEHOLDER]: NO_CREDENTIAL_SENTENCE,
|
|
1237
|
+
[NOT_THIS_ACCOUNT_PLACEHOLDER]: NOT_THIS_ACCOUNT_SENTENCE,
|
|
1238
|
+
[UPDATE_CLAUDE_CODE_PLACEHOLDER]: UPDATE_CLAUDE_CODE_SENTENCE
|
|
1239
|
+
});
|
|
1221
1240
|
|
|
1222
1241
|
// ../events/dist/phase-record.js
|
|
1223
1242
|
import { z as z11 } from "zod";
|
|
@@ -1322,7 +1341,6 @@ function forgetStoredCredential(serviceUrl, home) {
|
|
|
1322
1341
|
var ENGAGEMENTS_DIR = ENGAGEMENTS_DIR_NAME;
|
|
1323
1342
|
var ENV_FILENAME = ENGAGEMENT_ENV_FILENAME;
|
|
1324
1343
|
var HALFCYCLE_DIR = HALFCYCLE_DIR_NAME;
|
|
1325
|
-
var PIN_ENGAGEMENT_ID_FIELD = "engagementId";
|
|
1326
1344
|
var InvalidEngagementIdError = class extends Error {
|
|
1327
1345
|
constructor(engagementId) {
|
|
1328
1346
|
const shown = JSON.stringify(engagementId.length > 80 ? `${engagementId.slice(0, 80)}\u2026` : engagementId);
|
|
@@ -1640,6 +1658,15 @@ function bool(value, field) {
|
|
|
1640
1658
|
throw new UnreadableManifestError(`"${field}" is not true or false`);
|
|
1641
1659
|
return value;
|
|
1642
1660
|
}
|
|
1661
|
+
function appendedTextRecord(value, field) {
|
|
1662
|
+
if (!isRecord(value))
|
|
1663
|
+
throw new UnreadableManifestError(`"${field}" is not an object`);
|
|
1664
|
+
onlyKeys(value, ["created", "appended"], `"${field}"`);
|
|
1665
|
+
return {
|
|
1666
|
+
created: bool(value["created"], `${field}.created`),
|
|
1667
|
+
appended: stringArray(value["appended"], `${field}.appended`)
|
|
1668
|
+
};
|
|
1669
|
+
}
|
|
1643
1670
|
function onlyKeys(value, allowed, where) {
|
|
1644
1671
|
const extra = Object.keys(value).filter((k) => !allowed.includes(k));
|
|
1645
1672
|
if (extra.length > 0)
|
|
@@ -1654,7 +1681,7 @@ function parseManifest(text) {
|
|
|
1654
1681
|
}
|
|
1655
1682
|
if (!isRecord(raw))
|
|
1656
1683
|
throw new UnreadableManifestError("it is not a JSON object");
|
|
1657
|
-
onlyKeys(raw, ["format", "files", "createdDirs", "leftAlone", "settings", "mcp", "gitignore"], "the manifest");
|
|
1684
|
+
onlyKeys(raw, ["format", "files", "createdDirs", "leftAlone", "settings", "mcp", "gitignore", "gitattributes"], "the manifest");
|
|
1658
1685
|
if (raw["format"] !== INSTALL_MANIFEST_FORMAT) {
|
|
1659
1686
|
throw new UnreadableManifestError(`its format is not ${INSTALL_MANIFEST_FORMAT}`);
|
|
1660
1687
|
}
|
|
@@ -1715,14 +1742,10 @@ function parseManifest(text) {
|
|
|
1715
1742
|
...typeof m["entrySha256"] === "string" ? { entrySha256: m["entrySha256"] } : {}
|
|
1716
1743
|
};
|
|
1717
1744
|
}
|
|
1718
|
-
|
|
1719
|
-
if (!isRecord(g))
|
|
1745
|
+
if (raw["gitignore"] === void 0)
|
|
1720
1746
|
throw new UnreadableManifestError('"gitignore" is missing');
|
|
1721
|
-
|
|
1722
|
-
const
|
|
1723
|
-
created: bool(g["created"], "gitignore.created"),
|
|
1724
|
-
appended: stringArray(g["appended"], "gitignore.appended")
|
|
1725
|
-
};
|
|
1747
|
+
const gitignore = appendedTextRecord(raw["gitignore"], "gitignore");
|
|
1748
|
+
const gitattributes = raw["gitattributes"] === void 0 ? void 0 : appendedTextRecord(raw["gitattributes"], "gitattributes");
|
|
1726
1749
|
const paths = files.map((f) => f.path);
|
|
1727
1750
|
if (new Set(paths).size !== paths.length)
|
|
1728
1751
|
throw new UnreadableManifestError('"files" names a path twice');
|
|
@@ -1737,7 +1760,8 @@ function parseManifest(text) {
|
|
|
1737
1760
|
leftAlone,
|
|
1738
1761
|
settings,
|
|
1739
1762
|
...mcp !== void 0 ? { mcp } : {},
|
|
1740
|
-
gitignore
|
|
1763
|
+
gitignore,
|
|
1764
|
+
...gitattributes !== void 0 ? { gitattributes } : {}
|
|
1741
1765
|
};
|
|
1742
1766
|
}
|
|
1743
1767
|
function startManifest(previous, isMember, isMemberDir) {
|
|
@@ -1753,12 +1777,16 @@ function startManifest(previous, isMember, isMemberDir) {
|
|
|
1753
1777
|
createdDirs: new Set((previous?.createdDirs ?? []).filter(isMemberDir)),
|
|
1754
1778
|
settings: previous?.settings,
|
|
1755
1779
|
mcp: previous?.mcp,
|
|
1756
|
-
gitignore: previous?.gitignore
|
|
1780
|
+
gitignore: previous?.gitignore,
|
|
1781
|
+
gitattributes: previous?.gitattributes
|
|
1757
1782
|
};
|
|
1758
1783
|
}
|
|
1759
1784
|
function recordInstalledFile(draft, path, bytes) {
|
|
1760
1785
|
draft.files.set(path, { path, sha256: sha256Hex(bytes) });
|
|
1761
1786
|
}
|
|
1787
|
+
function recordRemovedFile(draft, path) {
|
|
1788
|
+
draft.files.delete(path);
|
|
1789
|
+
}
|
|
1762
1790
|
function recordPerMachineFile(draft, path) {
|
|
1763
1791
|
draft.files.set(path, { path, perMachine: true });
|
|
1764
1792
|
}
|
|
@@ -1801,8 +1829,15 @@ function recordMcp(draft, observed) {
|
|
|
1801
1829
|
};
|
|
1802
1830
|
}
|
|
1803
1831
|
function recordGitignore(draft, created, appended) {
|
|
1804
|
-
|
|
1805
|
-
|
|
1832
|
+
draft.gitignore = carryAppended(draft.gitignore, created, appended);
|
|
1833
|
+
}
|
|
1834
|
+
function recordGitattributes(draft, created, appended) {
|
|
1835
|
+
if (draft.gitattributes === void 0 && !created && appended.every((a) => a === ""))
|
|
1836
|
+
return;
|
|
1837
|
+
draft.gitattributes = carryAppended(draft.gitattributes, created, appended);
|
|
1838
|
+
}
|
|
1839
|
+
function carryAppended(prev, created, appended) {
|
|
1840
|
+
return {
|
|
1806
1841
|
created: prev?.created ?? created,
|
|
1807
1842
|
appended: [...prev?.appended ?? [], ...appended.filter((a) => a !== "")]
|
|
1808
1843
|
};
|
|
@@ -1826,7 +1861,8 @@ function finishManifest(draft) {
|
|
|
1826
1861
|
denyCreated: false
|
|
1827
1862
|
},
|
|
1828
1863
|
...draft.mcp !== void 0 ? { mcp: draft.mcp } : {},
|
|
1829
|
-
gitignore: draft.gitignore ?? { created: false, appended: [] }
|
|
1864
|
+
gitignore: draft.gitignore ?? { created: false, appended: [] },
|
|
1865
|
+
...draft.gitattributes !== void 0 ? { gitattributes: draft.gitattributes } : {}
|
|
1830
1866
|
};
|
|
1831
1867
|
}
|
|
1832
1868
|
function union(a, b) {
|
|
@@ -1977,11 +2013,12 @@ var WRITE_ALLOWLIST = [
|
|
|
1977
2013
|
".claude/hooks",
|
|
1978
2014
|
".claude/settings.json",
|
|
1979
2015
|
".halfcycle",
|
|
1980
|
-
|
|
2016
|
+
BUNDLE_PIN_REL,
|
|
1981
2017
|
".halfcycle/bin",
|
|
1982
2018
|
"docs/build-records",
|
|
1983
2019
|
"test/fixtures/captured",
|
|
1984
|
-
|
|
2020
|
+
MCP_REGISTRATION_REL,
|
|
2021
|
+
".gitattributes",
|
|
1985
2022
|
ZONE_B_DIR
|
|
1986
2023
|
];
|
|
1987
2024
|
function isAllowlisted(targetRelPath) {
|
|
@@ -1991,15 +2028,16 @@ function isAllowlisted(targetRelPath) {
|
|
|
1991
2028
|
return normalised === p || normalised.startsWith(p + "/");
|
|
1992
2029
|
});
|
|
1993
2030
|
}
|
|
1994
|
-
var BUNDLE_PIN_REL = ".halfcycle/bundle.json";
|
|
1995
2031
|
var PROJECT_IDENTITY_REL = ".halfcycle/project.json";
|
|
1996
2032
|
var CREW_ROSTER_REL = ".halfcycle/crew.json";
|
|
1997
2033
|
var CAPTURED_INDEX_REL = "test/fixtures/captured/manifest.json";
|
|
1998
2034
|
var GITIGNORE_REL = ".gitignore";
|
|
2035
|
+
var GITATTRIBUTES_REL = ".gitattributes";
|
|
1999
2036
|
function commandStubRel(name) {
|
|
2000
2037
|
return `.claude/commands/${name}.md`;
|
|
2001
2038
|
}
|
|
2002
|
-
var
|
|
2039
|
+
var RETIRED_MCP_HEADERS_HELPER_REL = ".halfcycle/mcp-headers.sh";
|
|
2040
|
+
var RETIRED_WRITE_SET_FILES = [RETIRED_MCP_HEADERS_HELPER_REL];
|
|
2003
2041
|
var RETIRED_DENY_PATTERNS = [];
|
|
2004
2042
|
function closedWriteSet() {
|
|
2005
2043
|
const files = /* @__PURE__ */ new Set([
|
|
@@ -2014,7 +2052,7 @@ function closedWriteSet() {
|
|
|
2014
2052
|
INSTALL_MANIFEST_REL,
|
|
2015
2053
|
...RETIRED_WRITE_SET_FILES
|
|
2016
2054
|
]);
|
|
2017
|
-
const merged = /* @__PURE__ */ new Set([SETTINGS_REL, MCP_REGISTRATION_REL, GITIGNORE_REL]);
|
|
2055
|
+
const merged = /* @__PURE__ */ new Set([SETTINGS_REL, MCP_REGISTRATION_REL, GITIGNORE_REL, GITATTRIBUTES_REL]);
|
|
2018
2056
|
const dirs = /* @__PURE__ */ new Set();
|
|
2019
2057
|
for (const path of [...files, ...merged]) {
|
|
2020
2058
|
for (let dir = posix.dirname(path); dir !== "."; dir = posix.dirname(dir))
|
|
@@ -2028,7 +2066,11 @@ function closedWriteSet() {
|
|
|
2028
2066
|
ownedDirs,
|
|
2029
2067
|
hookCommands: new Set(HALFCYCLE_HOOK_COMMANDS),
|
|
2030
2068
|
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])
|
|
2069
|
+
gitignoreLines: /* @__PURE__ */ new Set([GITIGNORE_HEADER, ...REQUIRED_GITIGNORE_ENTRIES, LEGACY_ENV_LOCAL_REL]),
|
|
2070
|
+
gitattributesLines: /* @__PURE__ */ new Set([
|
|
2071
|
+
GITATTRIBUTES_HEADER,
|
|
2072
|
+
...lfRules([...Object.keys(OWNED_GENERATED_HEADERS), ...RETIRED_WRITE_SET_FILES])
|
|
2073
|
+
])
|
|
2032
2074
|
};
|
|
2033
2075
|
}
|
|
2034
2076
|
function readPreviousManifest(targetRepo) {
|
|
@@ -2092,15 +2134,19 @@ function recordMcpAdoptionOnly(draft, existingText) {
|
|
|
2092
2134
|
}
|
|
2093
2135
|
function recordGitignoreReconcile(draft, before, after) {
|
|
2094
2136
|
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
|
-
}
|
|
2137
|
+
const appended = appendedText(before, after);
|
|
2102
2138
|
recordGitignore(draft, before === null && after !== null, [adopted, appended]);
|
|
2103
2139
|
}
|
|
2140
|
+
function recordGitattributesReconcile(draft, before, after) {
|
|
2141
|
+
recordGitattributes(draft, before === null && after !== null, [appendedText(before, after)]);
|
|
2142
|
+
}
|
|
2143
|
+
function appendedText(before, after) {
|
|
2144
|
+
if (after === null || after === before)
|
|
2145
|
+
return "";
|
|
2146
|
+
if (before === null)
|
|
2147
|
+
return after;
|
|
2148
|
+
return after.startsWith(before) ? after.slice(before.length) : "";
|
|
2149
|
+
}
|
|
2104
2150
|
function assertManifestInWriteSet(manifest, writeSet) {
|
|
2105
2151
|
const strays = [
|
|
2106
2152
|
...manifest.files.map((f) => f.path).filter((p) => !writeSet.files.has(p)),
|
|
@@ -2115,7 +2161,8 @@ function findNotShared(targetRepo, manifest, collidedPaths) {
|
|
|
2115
2161
|
const merged = [
|
|
2116
2162
|
SETTINGS_REL,
|
|
2117
2163
|
...manifest.mcp !== void 0 ? [MCP_REGISTRATION_REL] : [],
|
|
2118
|
-
...manifest.gitignore.created || manifest.gitignore.appended.length > 0 ? [GITIGNORE_REL] : []
|
|
2164
|
+
...manifest.gitignore.created || manifest.gitignore.appended.length > 0 ? [GITIGNORE_REL] : [],
|
|
2165
|
+
...manifest.gitattributes !== void 0 && (manifest.gitattributes.created || manifest.gitattributes.appended.length > 0) ? [GITATTRIBUTES_REL] : []
|
|
2119
2166
|
];
|
|
2120
2167
|
const collided = new Set(collidedPaths);
|
|
2121
2168
|
const dirVerdicts = /* @__PURE__ */ new Map();
|
|
@@ -2343,6 +2390,20 @@ exec node "$PROJECT_DIR/${VENDORED_BIN_REL}" "$@"
|
|
|
2343
2390
|
}
|
|
2344
2391
|
var GENERATED_SESSION_START_HEADER = "# Session-start marker \u2014 generated by";
|
|
2345
2392
|
var SESSION_ID_FROM_HOOK_INPUT_SH = `SESSION_ID="$(printf '%s' "$INPUT" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{const j=JSON.parse(s);process.stdout.write(typeof j.session_id==="string"?j.session_id:"")}catch{process.stdout.write("")}})' 2>/dev/null || true)"`;
|
|
2393
|
+
var REPO_HASH_FROM_ROOT_SH = `HASH="$(printf '%s' "$REPO_ROOT" | node -e 'const c=[];process.stdin.on("data",d=>c.push(d)).on("end",()=>{process.stdout.write(require("node:crypto").createHash("sha256").update(Buffer.concat(c)).digest("hex").slice(0,12))})' 2>/dev/null || true)"`;
|
|
2394
|
+
function noNodeReplyShell() {
|
|
2395
|
+
const reply = JSON.stringify({
|
|
2396
|
+
systemMessage: NO_NODE_SENTENCE,
|
|
2397
|
+
hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: NO_NODE_SENTENCE }
|
|
2398
|
+
});
|
|
2399
|
+
if (reply.includes("'")) {
|
|
2400
|
+
throw new Error("[bundle install] the no-Node notice cannot be single-quoted; refusing to write the session-start script.");
|
|
2401
|
+
}
|
|
2402
|
+
return `if ! command -v node >/dev/null 2>&1; then
|
|
2403
|
+
printf '%s\\n' '${reply}'
|
|
2404
|
+
exit 0
|
|
2405
|
+
fi`;
|
|
2406
|
+
}
|
|
2346
2407
|
function generateSessionStartMarker() {
|
|
2347
2408
|
return `#!/usr/bin/env bash
|
|
2348
2409
|
${GENERATED_SESSION_START_HEADER} the Halfcycle installer.
|
|
@@ -2351,10 +2412,15 @@ ${GENERATED_SESSION_START_HEADER} the Halfcycle installer.
|
|
|
2351
2412
|
# it began instead of only against the latest commit. The session id comes in on
|
|
2352
2413
|
# stdin with the hook input; without one, the older session-less name is written
|
|
2353
2414
|
# and the stop-time check compares against the latest commit as it used to.
|
|
2415
|
+
#
|
|
2416
|
+
# Without Node nothing Halfcycle installs here can run, so this says so once and
|
|
2417
|
+
# writes no marker.
|
|
2418
|
+
${noNodeReplyShell()}
|
|
2354
2419
|
INPUT="$(cat 2>/dev/null || true)"
|
|
2355
2420
|
REPO_ROOT="\${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null)}"
|
|
2356
2421
|
if [ -z "$REPO_ROOT" ]; then exit 0; fi
|
|
2357
|
-
|
|
2422
|
+
${REPO_HASH_FROM_ROOT_SH}
|
|
2423
|
+
if [ -z "$HASH" ]; then exit 0; fi
|
|
2358
2424
|
${SESSION_ID_FROM_HOOK_INPUT_SH}
|
|
2359
2425
|
MARKER_DIR="\${TMPDIR:-/tmp}"
|
|
2360
2426
|
MARKER_DIR="\${MARKER_DIR%/}" # strip trailing slash (macOS TMPDIR ends in /)
|
|
@@ -2364,64 +2430,6 @@ else
|
|
|
2364
2430
|
MARKER_FILE="\${MARKER_DIR}/halfcycle-session-\${HASH}.ref"
|
|
2365
2431
|
fi
|
|
2366
2432
|
git -C "$REPO_ROOT" rev-parse HEAD > "$MARKER_FILE" 2>/dev/null || true
|
|
2367
|
-
|
|
2368
|
-
# ---------------------------------------------------------------------------
|
|
2369
|
-
# STANDING GUARD-COVERAGE STATEMENT (T-27; two arms since T-02b).
|
|
2370
|
-
#
|
|
2371
|
-
# A repository with no credential on this machine is UNGUARDED: the PostToolUse
|
|
2372
|
-
# hook will find nothing to evaluate with, and it says so once per edit, after
|
|
2373
|
-
# the edit. This says it once, up front, before anything is written \u2014 which is
|
|
2374
|
-
# the one thing a session-start hook can do that nothing else can.
|
|
2375
|
-
#
|
|
2376
|
-
# TWO REASONS TO HAVE NO CREDENTIAL, TWO DIFFERENT THINGS TO SAY (T-02b,
|
|
2377
|
-
# solo-project-ownership \u2014 outcome B). "You have not signed in here yet" is
|
|
2378
|
-
# fixed by re-running the installer; "this is not your project" is permanent,
|
|
2379
|
-
# and the same remedy would send the reader in a circle forever. The two are
|
|
2380
|
-
# told apart by HALFCYCLE_ENV_NOT_THIS_ACCOUNT, set by \`halfcycle_env_file\`
|
|
2381
|
-
# below from a machine-level marker beside the credential store.
|
|
2382
|
-
#
|
|
2383
|
-
# Everything below runs in a SUBSHELL: it sources a credential store, and a
|
|
2384
|
-
# session-start hook must not leak an engagement's variables into whatever the
|
|
2385
|
-
# editor runs next. Any failure inside it is swallowed; this script's exit
|
|
2386
|
-
# status is 0 either way, and the marker above has already been written.
|
|
2387
|
-
# ---------------------------------------------------------------------------
|
|
2388
|
-
${engagementResolutionShell()}
|
|
2389
|
-
|
|
2390
|
-
(
|
|
2391
|
-
if halfcycle_env_file "$REPO_ROOT"; then
|
|
2392
|
-
set -a
|
|
2393
|
-
. "$HALFCYCLE_ENV_FILE"
|
|
2394
|
-
set +a
|
|
2395
|
-
fi
|
|
2396
|
-
if [ "$HALFCYCLE_ENV_NOT_THIS_ACCOUNT" = "1" ]; then
|
|
2397
|
-
# OUTCOME B (T-02b, solo-project-ownership). This is not the missing-keys
|
|
2398
|
-
# case below wearing different words: there is nothing to fix, and the loop
|
|
2399
|
-
# that follows is skipped entirely rather than run and then overridden \u2014 a
|
|
2400
|
-
# repository whose credential is absent for THIS reason must never be told
|
|
2401
|
-
# to re-run the installer, which can never obtain one for it.
|
|
2402
|
-
echo "[halfcycle] This project belongs to another Halfcycle account, so nothing here is checked."
|
|
2403
|
-
echo "[halfcycle] Your work carries on exactly as it would without Halfcycle \u2014 no edit is"
|
|
2404
|
-
echo "[halfcycle] evaluated, and nothing you do is reported as having passed."
|
|
2405
|
-
echo "[halfcycle] Halfcycle is Solo today \u2014 one developer per project \u2014 with Team plans coming."
|
|
2406
|
-
echo "[halfcycle] Ask the project's owner for view access."
|
|
2407
|
-
echo "[halfcycle] If this account is wrong, sign in as the one that owns the project and run"
|
|
2408
|
-
echo "[halfcycle] \\"npx halfcycle\\" again."
|
|
2409
|
-
else
|
|
2410
|
-
hc_missing=""
|
|
2411
|
-
for hc_key in ${GUARD_COVERAGE_REQUIRED_KEYS.join(" ")}; do
|
|
2412
|
-
eval "hc_value=\\\${$hc_key:-}"
|
|
2413
|
-
if [ -z "$hc_value" ]; then hc_missing="$hc_missing $hc_key"; fi
|
|
2414
|
-
done
|
|
2415
|
-
if [ -n "$hc_missing" ]; then
|
|
2416
|
-
echo "[halfcycle] NO GUARD COVERAGE IN THIS REPOSITORY. Nothing will evaluate the edits made in"
|
|
2417
|
-
echo "[halfcycle] this session: this machine has no Halfcycle credential for it, so the guard hook"
|
|
2418
|
-
echo "[halfcycle] has nothing to call with (missing:$hc_missing)."
|
|
2419
|
-
echo "[halfcycle] Run \\"npx halfcycle\\" in this repository to fix it \u2014 it signs you in through your"
|
|
2420
|
-
echo "[halfcycle] browser if needed, writes the credential to \\$HOME/.halfcycle outside this tree,"
|
|
2421
|
-
echo "[halfcycle] and needs nothing configured first."
|
|
2422
|
-
fi
|
|
2423
|
-
fi
|
|
2424
|
-
) 2>/dev/null || true
|
|
2425
2433
|
exit 0
|
|
2426
2434
|
`;
|
|
2427
2435
|
}
|
|
@@ -2437,7 +2445,8 @@ INPUT="$(cat 2>/dev/null || true)"
|
|
|
2437
2445
|
|
|
2438
2446
|
REPO_ROOT="\${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null)}"
|
|
2439
2447
|
if [ -z "$REPO_ROOT" ]; then exit 0; fi
|
|
2440
|
-
|
|
2448
|
+
${REPO_HASH_FROM_ROOT_SH}
|
|
2449
|
+
if [ -z "$HASH" ]; then exit 0; fi
|
|
2441
2450
|
|
|
2442
2451
|
# The session id arrives on stdin as part of the hook payload. Empty when absent.
|
|
2443
2452
|
${SESSION_ID_FROM_HOOK_INPUT_SH}
|
|
@@ -2484,7 +2493,8 @@ set -e
|
|
|
2484
2493
|
|
|
2485
2494
|
# The project root is resolved from THIS SCRIPT'S OWN LOCATION, never from the
|
|
2486
2495
|
# 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
|
|
2496
|
+
# .mcp.json the editor loaded, which is the bug the editor's headers command
|
|
2497
|
+
# already paid for.
|
|
2488
2498
|
HC_SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
|
2489
2499
|
HC_PROJECT_ROOT=$(CDPATH= cd -- "$HC_SCRIPT_DIR/../.." && pwd)
|
|
2490
2500
|
HC_REGISTRATION="$HC_PROJECT_ROOT/${MCP_REGISTRATION_REL}"
|
|
@@ -2531,7 +2541,9 @@ function isTrackedAndClean(targetRepo, relPath) {
|
|
|
2531
2541
|
return true;
|
|
2532
2542
|
}
|
|
2533
2543
|
function removeLegacyCiStanza(targetRepo) {
|
|
2534
|
-
|
|
2544
|
+
return removeRetiredGeneratedFile(targetRepo, ".halfcycle/ci-stanza.yml", 0, CI_STANZA_KNOWN_HEADER_HASHES);
|
|
2545
|
+
}
|
|
2546
|
+
function removeRetiredGeneratedFile(targetRepo, rel, headerLineIndex, knownHeaderHashes) {
|
|
2535
2547
|
const path = join5(targetRepo, rel);
|
|
2536
2548
|
if (!existsSync3(path))
|
|
2537
2549
|
return "absent";
|
|
@@ -2541,10 +2553,9 @@ function removeLegacyCiStanza(targetRepo) {
|
|
|
2541
2553
|
} catch {
|
|
2542
2554
|
return "failed";
|
|
2543
2555
|
}
|
|
2544
|
-
const headerLine = raw.split("\n")[
|
|
2556
|
+
const headerLine = raw.split("\n")[headerLineIndex] ?? "";
|
|
2545
2557
|
const digest = createHash2("sha256").update(headerLine, "utf-8").digest("hex");
|
|
2546
|
-
|
|
2547
|
-
if (!ours)
|
|
2558
|
+
if (!knownHeaderHashes.includes(digest))
|
|
2548
2559
|
return "kept-foreign";
|
|
2549
2560
|
if (!isTrackedAndClean(targetRepo, rel))
|
|
2550
2561
|
return "kept-uncommitted";
|
|
@@ -2555,205 +2566,33 @@ function removeLegacyCiStanza(targetRepo) {
|
|
|
2555
2566
|
}
|
|
2556
2567
|
return "removed";
|
|
2557
2568
|
}
|
|
2558
|
-
var
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2569
|
+
var MCP_HEADERS_HELPER_COMMAND = `node -e "${[
|
|
2570
|
+
`const p=require('path'),f=require('fs'),c=require('child_process');`,
|
|
2571
|
+
`let d=process.cwd();`,
|
|
2572
|
+
`for(;;){`,
|
|
2573
|
+
`const b=p.join(d,'${VENDORED_BIN_REL}');`,
|
|
2574
|
+
`if(f.existsSync(b)){`,
|
|
2575
|
+
`const r=c.spawnSync(process.execPath,[b,'mcp-headers'],{stdio:'inherit'});`,
|
|
2576
|
+
`process.exit(typeof r.status==='number'?r.status:1)}`,
|
|
2577
|
+
`const u=p.dirname(d);`,
|
|
2578
|
+
`if(u===d)break;`,
|
|
2579
|
+
`d=u}`,
|
|
2580
|
+
`console.error('halfcycle: no ${VENDORED_BIN_REL} at or above '+process.cwd()+'; run npx halfcycle in the project folder');`,
|
|
2581
|
+
`process.exit(1)`
|
|
2582
|
+
].join("")}"`;
|
|
2583
|
+
var MCP_HEADERS_KNOWN_HEADER_HASHES = [
|
|
2584
|
+
"37a733ea588b3361b2bbfd86c44e0c95da30d7af6acffd1243ece83c811f0af2",
|
|
2585
|
+
"6732ec6d2d2bf7cb33017749a3d021b59af653dcd07c3811693b5a242d44b1a1"
|
|
2586
|
+
];
|
|
2587
|
+
function removeRetiredMcpHeadersHelper(targetRepo) {
|
|
2588
|
+
return removeRetiredGeneratedFile(targetRepo, RETIRED_MCP_HEADERS_HELPER_REL, 1, MCP_HEADERS_KNOWN_HEADER_HASHES);
|
|
2589
|
+
}
|
|
2563
2590
|
var OWNED_GENERATED_HEADERS = {
|
|
2564
2591
|
".claude/hooks/guard-runner.sh": GENERATED_GUARD_RUNNER_HEADER,
|
|
2565
2592
|
".claude/hooks/user-prompt-reminder.sh": GENERATED_USER_PROMPT_REMINDER_HEADER,
|
|
2566
2593
|
".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
|
|
2594
|
+
".claude/hooks/session-start-marker.sh": GENERATED_SESSION_START_HEADER
|
|
2569
2595
|
};
|
|
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
2596
|
function generateMcpRegistration(existing, mcpOrigin) {
|
|
2758
2597
|
let base = { mcpServers: {} };
|
|
2759
2598
|
if (existing !== null) {
|
|
@@ -2820,6 +2659,42 @@ ${header}
|
|
|
2820
2659
|
return "failed";
|
|
2821
2660
|
}
|
|
2822
2661
|
}
|
|
2662
|
+
var GITATTRIBUTES_HEADER = "# Halfcycle \u2014 generated scripts keep LF line endings";
|
|
2663
|
+
function readTextOrNull(path) {
|
|
2664
|
+
try {
|
|
2665
|
+
return readFileSync5(path, "utf-8");
|
|
2666
|
+
} catch {
|
|
2667
|
+
return null;
|
|
2668
|
+
}
|
|
2669
|
+
}
|
|
2670
|
+
function lfRules(paths) {
|
|
2671
|
+
return [...paths].filter((path) => path.endsWith(".sh")).map((path) => `${path} text eol=lf`);
|
|
2672
|
+
}
|
|
2673
|
+
var REQUIRED_GITATTRIBUTES_RULES = lfRules(Object.keys(OWNED_GENERATED_HEADERS));
|
|
2674
|
+
function reconcileGitattributes(targetRepo) {
|
|
2675
|
+
const path = join5(targetRepo, GITATTRIBUTES_REL);
|
|
2676
|
+
const rules = REQUIRED_GITATTRIBUTES_RULES;
|
|
2677
|
+
const routed = [];
|
|
2678
|
+
try {
|
|
2679
|
+
if (lstatOrNull(path)?.isSymbolicLink() === true)
|
|
2680
|
+
return "failed";
|
|
2681
|
+
if (!existsSync3(path)) {
|
|
2682
|
+
writeAllowlisted(path, targetRepo, [GITATTRIBUTES_HEADER, ...rules].join("\n") + "\n", routed);
|
|
2683
|
+
return "written";
|
|
2684
|
+
}
|
|
2685
|
+
const existing = readFileSync5(path, "utf-8");
|
|
2686
|
+
const present = new Set(existing.split("\n").map((l) => l.trim()));
|
|
2687
|
+
const toAdd = rules.filter((rule) => !present.has(rule));
|
|
2688
|
+
if (toAdd.length === 0)
|
|
2689
|
+
return "skipped";
|
|
2690
|
+
const separator = existing === "" ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
|
|
2691
|
+
const suffix = separator + [GITATTRIBUTES_HEADER, ...toAdd].join("\n") + "\n";
|
|
2692
|
+
writeAllowlisted(path, targetRepo, existing + suffix, routed);
|
|
2693
|
+
return "written";
|
|
2694
|
+
} catch {
|
|
2695
|
+
return "failed";
|
|
2696
|
+
}
|
|
2697
|
+
}
|
|
2823
2698
|
function previousPinForEngagement(targetRepoRoot, engagementId) {
|
|
2824
2699
|
try {
|
|
2825
2700
|
const existing = readBundlePin(targetRepoRoot);
|
|
@@ -3070,9 +2945,6 @@ async function install(options) {
|
|
|
3070
2945
|
break;
|
|
3071
2946
|
}
|
|
3072
2947
|
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
2948
|
const mcpPath = join5(targetRepo, MCP_REGISTRATION_REL);
|
|
3077
2949
|
const existingMcp = existsSync3(mcpPath) ? readFileSync5(mcpPath, "utf-8") : null;
|
|
3078
2950
|
const mcpContent = generateMcpRegistration(existingMcp, credential.mcpUrl);
|
|
@@ -3084,6 +2956,24 @@ async function install(options) {
|
|
|
3084
2956
|
} else {
|
|
3085
2957
|
report.skippedPaths.push(MCP_REGISTRATION_REL);
|
|
3086
2958
|
}
|
|
2959
|
+
const helperRel = RETIRED_MCP_HEADERS_HELPER_REL;
|
|
2960
|
+
switch (removeRetiredMcpHeadersHelper(targetRepo)) {
|
|
2961
|
+
case "removed":
|
|
2962
|
+
recordRemovedFile(draft, helperRel);
|
|
2963
|
+
report.writtenPaths.push(`${helperRel} (REMOVED \u2014 Halfcycle no longer uses this file)`);
|
|
2964
|
+
break;
|
|
2965
|
+
case "kept-foreign":
|
|
2966
|
+
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)`);
|
|
2967
|
+
break;
|
|
2968
|
+
case "kept-uncommitted":
|
|
2969
|
+
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)`);
|
|
2970
|
+
break;
|
|
2971
|
+
case "failed":
|
|
2972
|
+
report.skippedPaths.push(`${helperRel} (could not be removed \u2014 Halfcycle no longer uses this file, DELETE IT BY HAND)`);
|
|
2973
|
+
break;
|
|
2974
|
+
case "absent":
|
|
2975
|
+
break;
|
|
2976
|
+
}
|
|
3087
2977
|
} else {
|
|
3088
2978
|
report.skippedPaths.push(`${MCP_REGISTRATION_REL} (no MCP origin \u2014 no credential supplied)`);
|
|
3089
2979
|
const mcpPath = join5(targetRepo, MCP_REGISTRATION_REL);
|
|
@@ -3099,6 +2989,15 @@ async function install(options) {
|
|
|
3099
2989
|
} else {
|
|
3100
2990
|
record(report, gitignoreOutcome, ".gitignore");
|
|
3101
2991
|
}
|
|
2992
|
+
const gitattributesPath = join5(targetRepo, GITATTRIBUTES_REL);
|
|
2993
|
+
const gitattributesBefore = readTextOrNull(gitattributesPath);
|
|
2994
|
+
const gitattributesOutcome = reconcileGitattributes(targetRepo);
|
|
2995
|
+
recordGitattributesReconcile(draft, gitattributesBefore, readTextOrNull(gitattributesPath));
|
|
2996
|
+
if (gitattributesOutcome === "failed") {
|
|
2997
|
+
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)`);
|
|
2998
|
+
} else {
|
|
2999
|
+
record(report, gitattributesOutcome, GITATTRIBUTES_REL);
|
|
3000
|
+
}
|
|
3102
3001
|
const identityOutcome = writeProjectIdentity(targetRepo);
|
|
3103
3002
|
record(report, identityOutcome, PROJECT_IDENTITY_REL);
|
|
3104
3003
|
noteFile(draft, targetRepo, PROJECT_IDENTITY_REL, identityOutcome);
|
|
@@ -3257,6 +3156,7 @@ async function bindLoopback(env = process.env, engagementId) {
|
|
|
3257
3156
|
const state = randomBytes(32).toString("hex");
|
|
3258
3157
|
let pending = null;
|
|
3259
3158
|
let deliver = null;
|
|
3159
|
+
let abandon = null;
|
|
3260
3160
|
const server = createServer(createCallbackHandler(state, (callback) => {
|
|
3261
3161
|
if (deliver)
|
|
3262
3162
|
deliver(callback);
|
|
@@ -3298,12 +3198,18 @@ async function bindLoopback(env = process.env, engagementId) {
|
|
|
3298
3198
|
clearInterval(ticker);
|
|
3299
3199
|
clearTimeout(timer);
|
|
3300
3200
|
deliver = null;
|
|
3201
|
+
abandon = null;
|
|
3301
3202
|
resolve4(value);
|
|
3302
3203
|
};
|
|
3303
3204
|
const timer = setTimeout(() => finish(null), Math.max(0, timeoutMs));
|
|
3304
3205
|
deliver = finish;
|
|
3206
|
+
abandon = () => finish(null);
|
|
3305
3207
|
}),
|
|
3306
|
-
close: () =>
|
|
3208
|
+
close: () => {
|
|
3209
|
+
if (abandon !== null)
|
|
3210
|
+
abandon();
|
|
3211
|
+
return closeServer(server);
|
|
3212
|
+
}
|
|
3307
3213
|
};
|
|
3308
3214
|
}
|
|
3309
3215
|
function closeServer(server) {
|
|
@@ -3337,8 +3243,13 @@ function defaultSleep(ms) {
|
|
|
3337
3243
|
function browserOpenCommand(os, url) {
|
|
3338
3244
|
if (os === "darwin")
|
|
3339
3245
|
return { command: "open", args: [url] };
|
|
3340
|
-
if (os === "win32")
|
|
3341
|
-
return {
|
|
3246
|
+
if (os === "win32") {
|
|
3247
|
+
return {
|
|
3248
|
+
command: "cmd.exe",
|
|
3249
|
+
args: ["/d", "/s", "/c", `"start "" "${url}""`],
|
|
3250
|
+
windowsVerbatimArguments: true
|
|
3251
|
+
};
|
|
3252
|
+
}
|
|
3342
3253
|
return { command: "xdg-open", args: [url] };
|
|
3343
3254
|
}
|
|
3344
3255
|
function isOpenableUrl(url) {
|
|
@@ -3349,19 +3260,35 @@ function isOpenableUrl(url) {
|
|
|
3349
3260
|
return false;
|
|
3350
3261
|
}
|
|
3351
3262
|
}
|
|
3263
|
+
function hasWin32UnsafeOpenerChars(url) {
|
|
3264
|
+
if (url.includes('"') || url.includes("%"))
|
|
3265
|
+
return true;
|
|
3266
|
+
for (let i = 0; i < url.length; i++) {
|
|
3267
|
+
const code = url.charCodeAt(i);
|
|
3268
|
+
if (code <= 31 || code === 127)
|
|
3269
|
+
return true;
|
|
3270
|
+
}
|
|
3271
|
+
return false;
|
|
3272
|
+
}
|
|
3352
3273
|
var BROWSER_OPEN_GRACE_MS = 3e3;
|
|
3353
|
-
async function openBrowser(url, env = process.env) {
|
|
3274
|
+
async function openBrowser(url, env = process.env, os = platform3(), spawnImpl = spawn) {
|
|
3354
3275
|
if ((env["HALFCYCLE_NO_BROWSER"] ?? "").trim() !== "") {
|
|
3355
3276
|
return { opened: false, reason: "HALFCYCLE_NO_BROWSER is set in this environment" };
|
|
3356
3277
|
}
|
|
3357
3278
|
if (!isOpenableUrl(url)) {
|
|
3358
3279
|
return { opened: false, reason: "the sign-in address is not an http(s) URL this CLI will open" };
|
|
3359
3280
|
}
|
|
3360
|
-
|
|
3281
|
+
if (os === "win32" && hasWin32UnsafeOpenerChars(url)) {
|
|
3282
|
+
return {
|
|
3283
|
+
opened: false,
|
|
3284
|
+
reason: 'the sign-in address holds a " or %, or a control character, this opener will not pass to cmd.exe on Windows'
|
|
3285
|
+
};
|
|
3286
|
+
}
|
|
3287
|
+
const { command, args: args2, windowsVerbatimArguments } = browserOpenCommand(os, url);
|
|
3361
3288
|
return new Promise((resolve4) => {
|
|
3362
3289
|
let child;
|
|
3363
3290
|
try {
|
|
3364
|
-
child =
|
|
3291
|
+
child = spawnImpl(command, args2, { stdio: "ignore", env, windowsVerbatimArguments });
|
|
3365
3292
|
} catch (err) {
|
|
3366
3293
|
resolve4({
|
|
3367
3294
|
opened: false,
|
|
@@ -3433,82 +3360,107 @@ function tryRefusal(body) {
|
|
|
3433
3360
|
}
|
|
3434
3361
|
return { status: candidate.status, message: candidate.message };
|
|
3435
3362
|
}
|
|
3363
|
+
async function askControlOnce(tokenUrl, started) {
|
|
3364
|
+
let body;
|
|
3365
|
+
try {
|
|
3366
|
+
const res = await fetch(tokenUrl, {
|
|
3367
|
+
method: "POST",
|
|
3368
|
+
headers: { "content-type": "application/json" },
|
|
3369
|
+
body: JSON.stringify({ deviceCode: started.deviceCode })
|
|
3370
|
+
});
|
|
3371
|
+
body = await res.json().catch(() => null);
|
|
3372
|
+
} catch {
|
|
3373
|
+
return null;
|
|
3374
|
+
}
|
|
3375
|
+
const parsed = devicePollResponseSchema.safeParse(body);
|
|
3376
|
+
return parsed.success ? parsed.data : null;
|
|
3377
|
+
}
|
|
3378
|
+
function tokenUrlFor(serviceUrl) {
|
|
3379
|
+
return `${serviceUrl.replace(/\/+$/, "")}/auth/device/token`;
|
|
3380
|
+
}
|
|
3381
|
+
function localDeadline(started) {
|
|
3382
|
+
return new Date(started.expiresAt).getTime() + started.pollIntervalMs * DEADLINE_SLACK_INTERVALS;
|
|
3383
|
+
}
|
|
3436
3384
|
async function pollForCredential(serviceUrl, started, deps = {}) {
|
|
3437
3385
|
const write = deps.write ?? defaultWrite;
|
|
3438
3386
|
const sleep = deps.sleep ?? defaultSleep;
|
|
3439
3387
|
const now = deps.now ?? Date.now;
|
|
3440
|
-
const url =
|
|
3388
|
+
const url = tokenUrlFor(serviceUrl);
|
|
3441
3389
|
const interval = started.pollIntervalMs;
|
|
3442
|
-
const deadline =
|
|
3390
|
+
const deadline = localDeadline(started);
|
|
3443
3391
|
const startedAt = now();
|
|
3444
3392
|
let announcedAt = startedAt;
|
|
3445
3393
|
for (; ; ) {
|
|
3446
|
-
|
|
3447
|
-
|
|
3448
|
-
|
|
3449
|
-
|
|
3450
|
-
|
|
3451
|
-
|
|
3452
|
-
body: JSON.stringify({ deviceCode: started.deviceCode })
|
|
3453
|
-
});
|
|
3454
|
-
body = await res.json().catch(() => null);
|
|
3455
|
-
} catch {
|
|
3456
|
-
reachable = false;
|
|
3457
|
-
}
|
|
3458
|
-
if (reachable) {
|
|
3459
|
-
const parsed = devicePollResponseSchema.safeParse(body);
|
|
3460
|
-
if (parsed.success) {
|
|
3461
|
-
const answer = parsed.data;
|
|
3462
|
-
if (answer.status === DEVICE_AUTH_STATUS.APPROVED)
|
|
3463
|
-
return answer;
|
|
3464
|
-
if (answer.status !== DEVICE_AUTH_STATUS.PENDING) {
|
|
3465
|
-
throw new SignInRefused(answer.status, answer.message);
|
|
3466
|
-
}
|
|
3394
|
+
const answer = await askControlOnce(url, started);
|
|
3395
|
+
if (answer !== null) {
|
|
3396
|
+
if (answer.status === DEVICE_AUTH_STATUS.APPROVED)
|
|
3397
|
+
return answer;
|
|
3398
|
+
if (answer.status !== DEVICE_AUTH_STATUS.PENDING) {
|
|
3399
|
+
throw new SignInRefused(answer.status, answer.message);
|
|
3467
3400
|
}
|
|
3468
3401
|
}
|
|
3469
3402
|
if (now() >= deadline) {
|
|
3470
3403
|
throw new SignInRefused(SIGN_IN_TIMED_OUT, SIGN_IN_TIMEOUT_MESSAGE);
|
|
3471
3404
|
}
|
|
3472
|
-
if (now() - announcedAt >= STILL_WAITING_MS) {
|
|
3405
|
+
if (now() - announcedAt >= (deps.stillWaitingMs ?? STILL_WAITING_MS)) {
|
|
3473
3406
|
announcedAt = now();
|
|
3474
|
-
write(
|
|
3475
|
-
`);
|
|
3407
|
+
write(stillWaitingLine(announcedAt - startedAt, started));
|
|
3476
3408
|
}
|
|
3477
3409
|
await sleep(interval);
|
|
3478
3410
|
}
|
|
3479
3411
|
}
|
|
3412
|
+
function stillWaitingLine(elapsedMs, started) {
|
|
3413
|
+
return `[halfcycle] Still waiting for the browser (${Math.round(elapsedMs / 1e3)}s). Code: ${started.userCode}
|
|
3414
|
+
`;
|
|
3415
|
+
}
|
|
3480
3416
|
var LOOPBACK_TIMEOUT_MESSAGE = "Sign-in timed out: the browser never came back to this terminal. Nothing was created. Run the command again \u2014 or, if this machine cannot hand the browser back (a remote session, a container), run it with HALFCYCLE_NO_BROWSER=1 to use the code-based sign-in instead.";
|
|
3481
|
-
|
|
3482
|
-
|
|
3483
|
-
const
|
|
3484
|
-
const url =
|
|
3485
|
-
|
|
3486
|
-
|
|
3487
|
-
|
|
3488
|
-
|
|
3489
|
-
|
|
3490
|
-
|
|
3491
|
-
|
|
3492
|
-
|
|
3493
|
-
|
|
3494
|
-
|
|
3495
|
-
|
|
3496
|
-
|
|
3497
|
-
|
|
3498
|
-
|
|
3499
|
-
|
|
3417
|
+
async function pollBesideLoopback(serviceUrl, started, wake, deps) {
|
|
3418
|
+
const sleep = deps.sleep;
|
|
3419
|
+
const now = deps.now ?? Date.now;
|
|
3420
|
+
const url = tokenUrlFor(serviceUrl);
|
|
3421
|
+
const deadline = localDeadline(started);
|
|
3422
|
+
let arrived = false;
|
|
3423
|
+
let spent = false;
|
|
3424
|
+
let interrupt = null;
|
|
3425
|
+
void wake.then(() => {
|
|
3426
|
+
arrived = true;
|
|
3427
|
+
if (interrupt !== null)
|
|
3428
|
+
interrupt();
|
|
3429
|
+
});
|
|
3430
|
+
for (; ; ) {
|
|
3431
|
+
const answer = await askControlOnce(url, started);
|
|
3432
|
+
if (answer !== null) {
|
|
3433
|
+
if (answer.status === DEVICE_AUTH_STATUS.APPROVED)
|
|
3434
|
+
return answer;
|
|
3435
|
+
if (answer.status !== DEVICE_AUTH_STATUS.PENDING) {
|
|
3436
|
+
throw new SignInRefused(answer.status, answer.message);
|
|
3437
|
+
}
|
|
3500
3438
|
}
|
|
3501
|
-
|
|
3502
|
-
|
|
3439
|
+
if (now() >= deadline) {
|
|
3440
|
+
throw new SignInRefused(SIGN_IN_TIMED_OUT, LOOPBACK_TIMEOUT_MESSAGE);
|
|
3441
|
+
}
|
|
3442
|
+
if (arrived && !spent) {
|
|
3443
|
+
spent = true;
|
|
3503
3444
|
continue;
|
|
3504
|
-
const answer = parsed.data;
|
|
3505
|
-
if (answer.status === DEVICE_AUTH_STATUS.APPROVED)
|
|
3506
|
-
return answer;
|
|
3507
|
-
if (answer.status !== DEVICE_AUTH_STATUS.PENDING) {
|
|
3508
|
-
throw new SignInRefused(answer.status, answer.message);
|
|
3509
3445
|
}
|
|
3446
|
+
if (sleep === void 0) {
|
|
3447
|
+
await new Promise((resolve4) => {
|
|
3448
|
+
const timer = setTimeout(resolve4, started.pollIntervalMs);
|
|
3449
|
+
interrupt = () => {
|
|
3450
|
+
clearTimeout(timer);
|
|
3451
|
+
resolve4();
|
|
3452
|
+
};
|
|
3453
|
+
});
|
|
3454
|
+
} else {
|
|
3455
|
+
await new Promise((resolve4) => {
|
|
3456
|
+
interrupt = resolve4;
|
|
3457
|
+
void sleep(started.pollIntervalMs).then(resolve4);
|
|
3458
|
+
});
|
|
3459
|
+
}
|
|
3460
|
+
interrupt = null;
|
|
3461
|
+
if (arrived)
|
|
3462
|
+
spent = true;
|
|
3510
3463
|
}
|
|
3511
|
-
throw new SignInRefused(SIGN_IN_TIMED_OUT, lastProblem);
|
|
3512
3464
|
}
|
|
3513
3465
|
async function signIn(serviceUrl, deps = {}) {
|
|
3514
3466
|
const write = deps.write ?? defaultWrite;
|
|
@@ -3533,24 +3485,16 @@ async function signIn(serviceUrl, deps = {}) {
|
|
|
3533
3485
|
const url = loopbackVerificationUrl(started.verificationUrl, bind.target);
|
|
3534
3486
|
const attempt = await openBrowser(url, env);
|
|
3535
3487
|
if (attempt.opened) {
|
|
3536
|
-
write(`[halfcycle] Opened your browser to finish signing in. This terminal is waiting.
|
|
3488
|
+
write(`[halfcycle] Opened your browser to finish signing in. Code: ${started.userCode}. This terminal is waiting.
|
|
3537
3489
|
`);
|
|
3538
|
-
const
|
|
3539
|
-
const
|
|
3540
|
-
timeoutMs:
|
|
3541
|
-
onStillWaiting: (elapsed) => (
|
|
3542
|
-
|
|
3543
|
-
// because a human is comparing it; on this path there is nothing to
|
|
3544
|
-
// compare and criterion 1 is that none is displayed AT ALL.
|
|
3545
|
-
write(`[halfcycle] Still waiting for the browser (${Math.round(elapsed / 1e3)}s).
|
|
3546
|
-
`)
|
|
3547
|
-
)
|
|
3490
|
+
const startedAt = now();
|
|
3491
|
+
const redirect = bind.waitForCallback({
|
|
3492
|
+
timeoutMs: localDeadline(started) - startedAt,
|
|
3493
|
+
onStillWaiting: (elapsed) => write(stillWaitingLine(elapsed, started)),
|
|
3494
|
+
stillWaitingMs: deps.stillWaitingMs ?? STILL_WAITING_MS
|
|
3548
3495
|
});
|
|
3496
|
+
const approved = await pollBesideLoopback(serviceUrl, started, redirect.then(() => void 0), deps);
|
|
3549
3497
|
await closeListener();
|
|
3550
|
-
if (callback === null) {
|
|
3551
|
-
throw new SignInRefused(SIGN_IN_TIMED_OUT, LOOPBACK_TIMEOUT_MESSAGE);
|
|
3552
|
-
}
|
|
3553
|
-
const approved = await collectDecidedCredential(serviceUrl, started, deps);
|
|
3554
3498
|
write(`[halfcycle] Signed in as account ${approved.accountId} \u2014 continuing.
|
|
3555
3499
|
`);
|
|
3556
3500
|
return {
|
|
@@ -4214,7 +4158,7 @@ function armScript(credential, act, label, plainLabel, home) {
|
|
|
4214
4158
|
}
|
|
4215
4159
|
function identitySwitchRemedy(home, interactive) {
|
|
4216
4160
|
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
|
|
4161
|
+
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
4162
|
}
|
|
4219
4163
|
async function confirmActingIdentity(serviceUrl, credential, act, deps = {}) {
|
|
4220
4164
|
const write = deps.write ?? ((text) => process.stdout.write(text));
|
|
@@ -4384,7 +4328,7 @@ async function mintBoardEnterCode(baseUrl, sessionToken) {
|
|
|
4384
4328
|
|
|
4385
4329
|
// dist/probe-mcp.js
|
|
4386
4330
|
var PROBE_TIMEOUT_MS = 5e3;
|
|
4387
|
-
async function probeMcpOrigin(mcpUrl) {
|
|
4331
|
+
async function probeMcpOrigin(mcpUrl, token) {
|
|
4388
4332
|
const url = mcpEndpointUrl(mcpUrl);
|
|
4389
4333
|
const controller = new AbortController();
|
|
4390
4334
|
const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
|
|
@@ -4392,32 +4336,71 @@ async function probeMcpOrigin(mcpUrl) {
|
|
|
4392
4336
|
try {
|
|
4393
4337
|
res = await fetch(url, {
|
|
4394
4338
|
method: "POST",
|
|
4395
|
-
headers: { "content-type": "application/json" },
|
|
4339
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
|
|
4396
4340
|
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }),
|
|
4341
|
+
redirect: "manual",
|
|
4397
4342
|
signal: controller.signal
|
|
4398
4343
|
});
|
|
4399
4344
|
} catch (err) {
|
|
4400
4345
|
return {
|
|
4401
4346
|
reached: false,
|
|
4347
|
+
credentialRefused: false,
|
|
4402
4348
|
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
4349
|
};
|
|
4404
4350
|
} finally {
|
|
4405
4351
|
clearTimeout(timer);
|
|
4406
4352
|
}
|
|
4407
4353
|
if (res.status === 404) {
|
|
4408
|
-
return { reached: false, problem: "that address answered, but it serves no /mcp endpoint" };
|
|
4354
|
+
return { reached: false, credentialRefused: false, problem: "that address answered, but it serves no /mcp endpoint" };
|
|
4355
|
+
}
|
|
4356
|
+
if (res.status === 401) {
|
|
4357
|
+
return {
|
|
4358
|
+
reached: false,
|
|
4359
|
+
credentialRefused: true,
|
|
4360
|
+
problem: "it answered, but refused this machine's Halfcycle credential"
|
|
4361
|
+
};
|
|
4362
|
+
}
|
|
4363
|
+
if (res.status >= 300 && res.status < 400) {
|
|
4364
|
+
return {
|
|
4365
|
+
reached: false,
|
|
4366
|
+
credentialRefused: false,
|
|
4367
|
+
problem: `it redirected elsewhere (${res.status}), which is not followed, so the credential was not sent on`
|
|
4368
|
+
};
|
|
4409
4369
|
}
|
|
4410
4370
|
if (!res.ok) {
|
|
4411
|
-
return { reached: false, problem: `it answered ${res.status}` };
|
|
4371
|
+
return { reached: false, credentialRefused: false, problem: `it answered ${res.status}` };
|
|
4412
4372
|
}
|
|
4413
4373
|
const body = await res.json().catch(() => null);
|
|
4414
4374
|
const name = body?.result?.serverInfo?.name;
|
|
4415
4375
|
if (typeof name !== "string" || name === "") {
|
|
4416
|
-
return { reached: false, problem: "it answered, but not as an MCP server" };
|
|
4376
|
+
return { reached: false, credentialRefused: false, problem: "it answered, but not as an MCP server" };
|
|
4417
4377
|
}
|
|
4418
4378
|
return { reached: true, serverName: name };
|
|
4419
4379
|
}
|
|
4420
4380
|
|
|
4381
|
+
// dist/claude-version-probe.js
|
|
4382
|
+
import { execFileSync as execFileSync3 } from "node:child_process";
|
|
4383
|
+
function probeClaudeCodeVersion(timeoutMs, deps = {}) {
|
|
4384
|
+
const platform4 = deps.platform ?? process.platform;
|
|
4385
|
+
const exec = deps.exec ?? execFileSync3;
|
|
4386
|
+
let raw;
|
|
4387
|
+
try {
|
|
4388
|
+
raw = platform4 === "win32" ? exec("cmd.exe", ["/d", "/s", "/c", '"claude --version"'], {
|
|
4389
|
+
encoding: "utf-8",
|
|
4390
|
+
timeout: timeoutMs,
|
|
4391
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
4392
|
+
windowsVerbatimArguments: true
|
|
4393
|
+
}) : exec("claude", ["--version"], {
|
|
4394
|
+
encoding: "utf-8",
|
|
4395
|
+
timeout: timeoutMs,
|
|
4396
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
4397
|
+
});
|
|
4398
|
+
} catch {
|
|
4399
|
+
return void 0;
|
|
4400
|
+
}
|
|
4401
|
+
return /(\d+\.\d+\.\d+)/.exec(raw)?.[1];
|
|
4402
|
+
}
|
|
4403
|
+
|
|
4421
4404
|
// dist/build-record/write.js
|
|
4422
4405
|
import { closeSync, existsSync as existsSync4, mkdirSync as mkdirSync5, openSync, readFileSync as readFileSync6, renameSync, rmSync as rmSync2, writeSync } from "node:fs";
|
|
4423
4406
|
import { basename as basename2, join as join6, resolve as resolve2, relative as relative2, isAbsolute as isAbsolute2 } from "node:path";
|
|
@@ -4993,7 +4976,7 @@ async function openPhase(credential, phase, entry, home) {
|
|
|
4993
4976
|
}
|
|
4994
4977
|
|
|
4995
4978
|
// dist/close-phase.js
|
|
4996
|
-
import { execFileSync as
|
|
4979
|
+
import { execFileSync as execFileSync4 } from "node:child_process";
|
|
4997
4980
|
var NO_STATEMENTS = { thesis: null, outcome: null, deviations: [] };
|
|
4998
4981
|
function readCloseCommit(repoRoot) {
|
|
4999
4982
|
if (repoRoot === void 0) {
|
|
@@ -5004,7 +4987,7 @@ function readCloseCommit(repoRoot) {
|
|
|
5004
4987
|
}
|
|
5005
4988
|
let out;
|
|
5006
4989
|
try {
|
|
5007
|
-
out =
|
|
4990
|
+
out = execFileSync4("git", ["-C", repoRoot, "rev-parse", "HEAD"], {
|
|
5008
4991
|
encoding: "utf-8",
|
|
5009
4992
|
env: gitEnvForNamedRepo(),
|
|
5010
4993
|
stdio: ["ignore", "pipe", "ignore"]
|
|
@@ -5299,7 +5282,7 @@ function validate(options) {
|
|
|
5299
5282
|
if (lexists(pinPath)) {
|
|
5300
5283
|
try {
|
|
5301
5284
|
const pin = JSON.parse(readFileSync8(pinPath, "utf-8"));
|
|
5302
|
-
const id = isPlainObject2(pin) ? pin[
|
|
5285
|
+
const id = isPlainObject2(pin) ? pin[PIN_ENGAGEMENT_ID_FIELD] : void 0;
|
|
5303
5286
|
engagementId = typeof id === "string" && id !== "" ? id : void 0;
|
|
5304
5287
|
} catch {
|
|
5305
5288
|
pinReadable = false;
|
|
@@ -5441,17 +5424,33 @@ function unmergeMcp(value, rec) {
|
|
|
5441
5424
|
}
|
|
5442
5425
|
return { changed, keptEntry };
|
|
5443
5426
|
}
|
|
5444
|
-
function
|
|
5427
|
+
function gitignoreRules(targetRepo, writeSet) {
|
|
5428
|
+
return {
|
|
5429
|
+
rel: GITIGNORE_REL,
|
|
5430
|
+
header: GITIGNORE_HEADER,
|
|
5431
|
+
ourLines: writeSet.gitignoreLines,
|
|
5432
|
+
keepLine: (line) => coveredPathExists(targetRepo, line)
|
|
5433
|
+
};
|
|
5434
|
+
}
|
|
5435
|
+
function gitattributesRules(writeSet) {
|
|
5436
|
+
return {
|
|
5437
|
+
rel: GITATTRIBUTES_REL,
|
|
5438
|
+
header: GITATTRIBUTES_HEADER,
|
|
5439
|
+
ourLines: writeSet.gitattributesLines,
|
|
5440
|
+
keepLine: () => false
|
|
5441
|
+
};
|
|
5442
|
+
}
|
|
5443
|
+
function trimAppendedBlocks(text, rec, rules) {
|
|
5445
5444
|
const keptLines = [];
|
|
5446
5445
|
const foreignLines = [];
|
|
5447
5446
|
let current = text;
|
|
5448
5447
|
for (const block of [...rec.appended].reverse()) {
|
|
5449
5448
|
const lines = block.split("\n").filter((line) => line !== "");
|
|
5450
|
-
const foreign = lines.filter((line) => !
|
|
5449
|
+
const foreign = lines.filter((line) => !rules.ourLines.has(line));
|
|
5451
5450
|
foreignLines.push(...foreign);
|
|
5452
|
-
const ours = lines.filter((line) =>
|
|
5453
|
-
const entries = ours.filter((line) => line !==
|
|
5454
|
-
const kept = entries.filter((line) =>
|
|
5451
|
+
const ours = lines.filter((line) => rules.ourLines.has(line));
|
|
5452
|
+
const entries = ours.filter((line) => line !== rules.header);
|
|
5453
|
+
const kept = entries.filter((line) => rules.keepLine(line));
|
|
5455
5454
|
keptLines.push(...kept);
|
|
5456
5455
|
if (kept.length === 0 && foreign.length === 0 && current.includes(block)) {
|
|
5457
5456
|
const at = current.lastIndexOf(block);
|
|
@@ -5459,13 +5458,13 @@ function trimGitignore(targetRepo, text, rec, writeSet) {
|
|
|
5459
5458
|
continue;
|
|
5460
5459
|
}
|
|
5461
5460
|
const separated = block.startsWith("\n") || block.includes("\n\n");
|
|
5462
|
-
const removable = ours.filter((line) => line ===
|
|
5461
|
+
const removable = ours.filter((line) => line === rules.header ? kept.length === 0 && foreign.length === 0 : !kept.includes(line));
|
|
5463
5462
|
for (const line of removable) {
|
|
5464
5463
|
const fileLines = current.split("\n");
|
|
5465
5464
|
const at = fileLines.lastIndexOf(line);
|
|
5466
5465
|
if (at < 0)
|
|
5467
5466
|
continue;
|
|
5468
|
-
const withSeparator = line ===
|
|
5467
|
+
const withSeparator = line === rules.header && separated && at > 0 && fileLines[at - 1] === "";
|
|
5469
5468
|
fileLines.splice(withSeparator ? at - 1 : at, withSeparator ? 2 : 1);
|
|
5470
5469
|
current = fileLines.join("\n");
|
|
5471
5470
|
}
|
|
@@ -5557,7 +5556,13 @@ function uninstall(options) {
|
|
|
5557
5556
|
continue;
|
|
5558
5557
|
removeEmptyCreatedDir(targetRepo, dir, writeSet, result, name);
|
|
5559
5558
|
}
|
|
5560
|
-
|
|
5559
|
+
const keptIgnoreLines = trimAppendedFile(targetRepo, manifest.gitignore, gitignoreRules(targetRepo, writeSet), result, name);
|
|
5560
|
+
for (const line of keptIgnoreLines) {
|
|
5561
|
+
result.keptIgnoreLines.push(`${line.trim()} \u2014 ${line.trim().replace(/^\//, "")} still exists`);
|
|
5562
|
+
}
|
|
5563
|
+
if (manifest.gitattributes !== void 0) {
|
|
5564
|
+
trimAppendedFile(targetRepo, manifest.gitattributes, gitattributesRules(writeSet), result, name);
|
|
5565
|
+
}
|
|
5561
5566
|
const credential = handleCredential(options, checked);
|
|
5562
5567
|
const credentialFailed = credential.kind === "failed";
|
|
5563
5568
|
if (result.failed.length === 0 && !credentialFailed) {
|
|
@@ -5671,40 +5676,39 @@ function removeEmptyCreatedDir(targetRepo, dir, writeSet, lists, name) {
|
|
|
5671
5676
|
name(lists.failed, `${dir}/`, dir);
|
|
5672
5677
|
}
|
|
5673
5678
|
}
|
|
5674
|
-
function
|
|
5679
|
+
function trimAppendedFile(targetRepo, rec, rules, lists, name) {
|
|
5680
|
+
const { rel } = rules;
|
|
5675
5681
|
if (rec.appended.length === 0)
|
|
5676
|
-
return;
|
|
5677
|
-
if (symlinkOnPath(targetRepo,
|
|
5678
|
-
name(lists.leftAlone, `${
|
|
5679
|
-
return;
|
|
5682
|
+
return [];
|
|
5683
|
+
if (symlinkOnPath(targetRepo, rel)) {
|
|
5684
|
+
name(lists.leftAlone, `${rel} (a symbolic link \u2014 not followed)`, rel);
|
|
5685
|
+
return [];
|
|
5680
5686
|
}
|
|
5681
|
-
const path = join8(targetRepo,
|
|
5687
|
+
const path = join8(targetRepo, rel);
|
|
5682
5688
|
let before;
|
|
5683
5689
|
try {
|
|
5684
5690
|
before = readFileSync8(path, "utf-8");
|
|
5685
5691
|
} catch {
|
|
5686
|
-
return;
|
|
5692
|
+
return [];
|
|
5687
5693
|
}
|
|
5688
|
-
const { text, keptLines, foreignLines } =
|
|
5694
|
+
const { text, keptLines, foreignLines } = trimAppendedBlocks(before, rec, rules);
|
|
5689
5695
|
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`);
|
|
5696
|
+
name(lists.leftAlone, `${rel}: the line ${JSON.stringify(line)} (Halfcycle never writes it, so it was kept)`);
|
|
5694
5697
|
}
|
|
5695
5698
|
if (text === before)
|
|
5696
|
-
return;
|
|
5699
|
+
return keptLines;
|
|
5697
5700
|
try {
|
|
5698
5701
|
if (text === "" && rec.created) {
|
|
5699
5702
|
rmSync3(path);
|
|
5700
|
-
name(lists.removed,
|
|
5703
|
+
name(lists.removed, rel, rel);
|
|
5701
5704
|
} else {
|
|
5702
5705
|
writeFileSync5(path, text, "utf-8");
|
|
5703
|
-
name(lists.restored,
|
|
5706
|
+
name(lists.restored, rel, rel);
|
|
5704
5707
|
}
|
|
5705
5708
|
} catch {
|
|
5706
|
-
name(lists.failed,
|
|
5709
|
+
name(lists.failed, rel, rel);
|
|
5707
5710
|
}
|
|
5711
|
+
return keptLines;
|
|
5708
5712
|
}
|
|
5709
5713
|
function handleCredential(options, checked) {
|
|
5710
5714
|
const { removeCredential, home } = options;
|
|
@@ -5820,7 +5824,6 @@ function renderUninstallReport(result, home) {
|
|
|
5820
5824
|
}
|
|
5821
5825
|
|
|
5822
5826
|
// dist/banner-facts.js
|
|
5823
|
-
import { execFileSync as execFileSync4 } from "node:child_process";
|
|
5824
5827
|
import { readFileSync as readFileSync9 } from "node:fs";
|
|
5825
5828
|
import { basename as basename3, join as join9, resolve as resolve3 } from "node:path";
|
|
5826
5829
|
var BRAND_URL = "halfcycle.ai";
|
|
@@ -5841,17 +5844,8 @@ function bundleVersion() {
|
|
|
5841
5844
|
return void 0;
|
|
5842
5845
|
}
|
|
5843
5846
|
}
|
|
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
|
-
}
|
|
5847
|
+
function claudeCodeVersion(deps = {}) {
|
|
5848
|
+
return probeClaudeCodeVersion(3e3, deps);
|
|
5855
5849
|
}
|
|
5856
5850
|
var REAL_PROBES = {
|
|
5857
5851
|
bundleVersion,
|
|
@@ -5952,21 +5946,10 @@ function compareVersions(a, b) {
|
|
|
5952
5946
|
}
|
|
5953
5947
|
return 0;
|
|
5954
5948
|
}
|
|
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 {
|
|
5964
|
-
return;
|
|
5965
|
-
}
|
|
5966
|
-
const found = /(\d+\.\d+\.\d+)/.exec(raw);
|
|
5967
|
-
if (!found)
|
|
5949
|
+
function reportClaudeCodeVersion(verbose2, deps = {}) {
|
|
5950
|
+
const version = probeClaudeCodeVersion(5e3, deps);
|
|
5951
|
+
if (version === void 0)
|
|
5968
5952
|
return;
|
|
5969
|
-
const version = found[1];
|
|
5970
5953
|
if (compareVersions(version, MIN_HEADERS_HELPER_VERSION) >= 0) {
|
|
5971
5954
|
if (verbose2) {
|
|
5972
5955
|
process.stdout.write(`[halfcycle] Claude Code ${version} detected \u2014 new enough for the credential helper.
|
|
@@ -6008,6 +5991,15 @@ function isHalfcycleMonorepo(dir) {
|
|
|
6008
5991
|
return false;
|
|
6009
5992
|
}
|
|
6010
5993
|
}
|
|
5994
|
+
function isHomeFolder(dir) {
|
|
5995
|
+
try {
|
|
5996
|
+
const target = realpathSync(dir);
|
|
5997
|
+
const home = realpathSync(homedir3());
|
|
5998
|
+
return process.platform === "win32" ? target.toLowerCase() === home.toLowerCase() : target === home;
|
|
5999
|
+
} catch {
|
|
6000
|
+
return false;
|
|
6001
|
+
}
|
|
6002
|
+
}
|
|
6011
6003
|
var RETIRED_VERBS = /* @__PURE__ */ new Map([
|
|
6012
6004
|
["ci", '"ci" was removed \u2014 CI setup is part of your project, not Halfcycle']
|
|
6013
6005
|
]);
|
|
@@ -6047,6 +6039,12 @@ ${USAGE}`);
|
|
|
6047
6039
|
product's own source tree \u2014 installing here would write over tracked files.
|
|
6048
6040
|
To see what this CLI does: halfcycle --help
|
|
6049
6041
|
To install into a real project: halfcycle <path-to-that-repo>
|
|
6042
|
+
`);
|
|
6043
|
+
process.exit(1);
|
|
6044
|
+
return;
|
|
6045
|
+
}
|
|
6046
|
+
if (isHomeFolder(targetRepo)) {
|
|
6047
|
+
process.stderr.write(`halfcycle: refusing to set up in your home folder (${targetRepo}). Run "npx halfcycle" inside a project folder.
|
|
6050
6048
|
`);
|
|
6051
6049
|
process.exit(1);
|
|
6052
6050
|
return;
|
|
@@ -6189,12 +6187,16 @@ ${USAGE}`);
|
|
|
6189
6187
|
`);
|
|
6190
6188
|
}
|
|
6191
6189
|
if (credential) {
|
|
6192
|
-
const probe = await probeMcpOrigin(credential.mcpUrl);
|
|
6190
|
+
const probe = await probeMcpOrigin(credential.mcpUrl, credential.token);
|
|
6193
6191
|
if (probe.reached) {
|
|
6194
6192
|
if (verbose) {
|
|
6195
6193
|
process.stdout.write(`[halfcycle] Method delivery (MCP): ${credential.mcpUrl} \u2014 reachable (${probe.serverName})
|
|
6196
6194
|
`);
|
|
6197
6195
|
}
|
|
6196
|
+
} else if (probe.credentialRefused) {
|
|
6197
|
+
process.stdout.write(`[halfcycle] Method delivery (MCP): ${credential.mcpUrl} \u2014 ${probe.problem}.
|
|
6198
|
+
[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.
|
|
6199
|
+
`);
|
|
6198
6200
|
} else {
|
|
6199
6201
|
process.stdout.write(`[halfcycle] Method delivery (MCP): ${credential.mcpUrl} \u2014 NOT reachable: ${probe.problem}.
|
|
6200
6202
|
[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 +6206,11 @@ ${USAGE}`);
|
|
|
6204
6206
|
if (credential && verbose) {
|
|
6205
6207
|
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
6208
|
`);
|
|
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
|
|
6209
|
+
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
6210
|
`);
|
|
6209
|
-
process.stdout.write(`[halfcycle] Expect a workspace trust prompt the first time you open this folder \u2014 the
|
|
6211
|
+
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
6212
|
`);
|
|
6211
|
-
process.stdout.write(`[halfcycle] If Halfcycle
|
|
6213
|
+
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
6214
|
`);
|
|
6213
6215
|
}
|
|
6214
6216
|
if (credential === void 0 && notThisAccountMessage !== void 0) {
|
|
@@ -6228,10 +6230,13 @@ ${USAGE}`);
|
|
|
6228
6230
|
if (result.notShared)
|
|
6229
6231
|
process.stdout.write(`${renderNotShared(result.notShared)}
|
|
6230
6232
|
`);
|
|
6233
|
+
if (credential) {
|
|
6234
|
+
process.stdout.write("[halfcycle] Commit the files Halfcycle set up here, so every copy of this project runs the same checks.\n");
|
|
6235
|
+
}
|
|
6231
6236
|
if (credential) {
|
|
6232
6237
|
process.stdout.write(`[halfcycle] Next: open this folder in Claude Code \u2014 accept the workspace-trust prompt, it is expected \u2014 then run /halfcycle-setup and answer what it asks about your project
|
|
6233
6238
|
`);
|
|
6234
|
-
process.stdout.write(`[halfcycle] Claude Code will
|
|
6239
|
+
process.stdout.write(`[halfcycle] Claude Code will then ask whether to use a new MCP server. The cursor starts on "Continue without using this MCP server" \u2014 choose to use it instead; without it Halfcycle will not connect.
|
|
6235
6240
|
`);
|
|
6236
6241
|
}
|
|
6237
6242
|
if (credential) {
|