@indigoai-us/hq-cli 5.91.0 → 5.93.0
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/CHANGELOG.md +32 -0
- package/assets/scaffold/core/scripts/archive-old-threads.sh +6 -2
- package/dist/commands/core.js +9 -19
- package/dist/lib/core-utils/archive-old-threads.d.ts +8 -0
- package/dist/lib/core-utils/archive-old-threads.js +97 -0
- package/dist/lib/core-utils/codex-skill-bridge-entry.d.ts +13 -0
- package/dist/lib/core-utils/codex-skill-bridge-entry.js +48 -0
- package/dist/lib/core-utils/codex-skill-bridge.d.ts +31 -0
- package/dist/lib/core-utils/codex-skill-bridge.js +505 -0
- package/dist/lib/core-utils/qmd-reindex-after-sync.d.ts +15 -0
- package/dist/lib/core-utils/qmd-reindex-after-sync.js +62 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,38 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.93.0]
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- `hq core archive-old-threads --gated` no longer fails on Linux. The retained
|
|
10
|
+
shell asset probed `stat -f %m` first, which on GNU coreutils is a filesystem
|
|
11
|
+
query: it consumed the format string as a path, printed `File: …` for the
|
|
12
|
+
real argument, and exited 0, so the `stat -c %Y` fallback never ran and the
|
|
13
|
+
age arithmetic aborted on an unbound `File`. The once-per-day gate exited 1
|
|
14
|
+
instead of gating. Probes GNU first now. (#327)
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- `hq core archive-old-threads` and `hq core qmd-reindex-after-sync` run as
|
|
19
|
+
native, tested TypeScript instead of bundled shell assets, proven equivalent
|
|
20
|
+
by a shell-vs-native differential suite. The reindex command now reuses the
|
|
21
|
+
existing search-index collection policy rather than restating the
|
|
22
|
+
registration convention, so the two can no longer drift. Scaffold scripts
|
|
23
|
+
still ship for loose HQ trees. (#327)
|
|
24
|
+
|
|
25
|
+
## [5.92.0]
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
|
|
29
|
+
- `hq core codex-skill-bridge` now runs as native, tested TypeScript instead of
|
|
30
|
+
a bundled shell asset, completing the `hq core` utility migration. Install,
|
|
31
|
+
status, doctor, dry-run, stale-HQ-root repair, blocked-target refusal, legacy
|
|
32
|
+
`.Codex` handling, and the local-config path rewrite are all preserved and
|
|
33
|
+
proven byte-identical to the shell implementation by a differential suite
|
|
34
|
+
covering sixteen scenarios. The scaffold script still ships for loose HQ
|
|
35
|
+
trees. (#325)
|
|
36
|
+
|
|
5
37
|
## [5.91.0]
|
|
6
38
|
|
|
7
39
|
### Changed
|
|
@@ -32,8 +32,12 @@ done
|
|
|
32
32
|
|
|
33
33
|
TOUCHFILE="workspace/.last-archive-run"
|
|
34
34
|
if [[ "$GATED" == "true" && -f "$TOUCHFILE" ]]; then
|
|
35
|
-
#
|
|
36
|
-
|
|
35
|
+
# GNU `stat -c %Y` first, then macOS `stat -f %m`. Order matters: on GNU,
|
|
36
|
+
# `-f` queries a FILE SYSTEM, so `stat -f %m <file>` treats `%m` as a path,
|
|
37
|
+
# prints `File: …` for the real argument, and still exits 0 — the fallback
|
|
38
|
+
# never fired and the arithmetic below died on unbound `File`, breaking
|
|
39
|
+
# --gated on Linux entirely. Matches core/scripts/lib/portable.sh.
|
|
40
|
+
mtime=$(stat -c %Y "$TOUCHFILE" 2>/dev/null || stat -f %m "$TOUCHFILE" 2>/dev/null || echo 0)
|
|
37
41
|
now=$(date +%s)
|
|
38
42
|
if [[ $((now - mtime)) -lt 86400 ]]; then
|
|
39
43
|
echo "archive-old-threads: gated (ran <24h ago, skipping)" >&2
|
package/dist/commands/core.js
CHANGED
|
@@ -41,6 +41,9 @@ import { ontologyReadmeDrift } from "../lib/core-utils/ontology-readme-drift.js"
|
|
|
41
41
|
import { resizeScreenshot } from "../lib/core-utils/resize-screenshot.js";
|
|
42
42
|
import { tokenUsageReport } from "../lib/core-utils/token-usage-report.js";
|
|
43
43
|
import { createWorktree } from "../lib/core-utils/worktree.js";
|
|
44
|
+
import { runCodexSkillBridgeCommand } from "../lib/core-utils/codex-skill-bridge-entry.js";
|
|
45
|
+
import { archiveOldThreads } from "../lib/core-utils/archive-old-threads.js";
|
|
46
|
+
import { qmdReindexAfterSync } from "../lib/core-utils/qmd-reindex-after-sync.js";
|
|
44
47
|
/**
|
|
45
48
|
* These commands are now native implementations. Their retained shell assets
|
|
46
49
|
* are deliberately claimed in SCAFFOLD_ONLY_ASSETS: loose HQ trees still ship
|
|
@@ -53,6 +56,9 @@ export const NATIVE_UTILITY_COMMANDS = [
|
|
|
53
56
|
{ name: "resize-screenshot", root: "cwd", summary: "Downscale a screenshot for context efficiency", run: (args) => resizeScreenshot(args) },
|
|
54
57
|
{ name: "token-usage-report", root: "cwd", summary: "Report session token usage", run: (args) => tokenUsageReport(args) },
|
|
55
58
|
{ name: "worktree", root: "live", summary: "Create a git worktree under workspace/worktrees/", run: (args, context) => createWorktree(args, { cwd: context.cwd, hqRoot: context.hqRoot }) },
|
|
59
|
+
{ name: "codex-skill-bridge", root: "live", summary: "Install/inspect the Codex skill bridge", run: (args, context) => runCodexSkillBridgeCommand(args, { root: context.hqRoot ?? context.cwd }) },
|
|
60
|
+
{ name: "archive-old-threads", root: "live", summary: "Archive aged session-thread files", run: (args, context) => archiveOldThreads(args, { hqRoot: context.hqRoot ?? context.cwd }) },
|
|
61
|
+
{ name: "qmd-reindex-after-sync", root: "cwd", summary: "Refresh qmd collections after a sync", run: (args, context) => qmdReindexAfterSync(args, { cwd: context.cwd }) },
|
|
56
62
|
];
|
|
57
63
|
export const NATIVE_SCAFFOLD_COMMANDS = [
|
|
58
64
|
{
|
|
@@ -159,12 +165,6 @@ export const REBUILD_INDEX_TARGETS = [
|
|
|
159
165
|
*/
|
|
160
166
|
export const SCAFFOLD_COMMANDS = [
|
|
161
167
|
// ---- One-shot migrations and maintenance (live tree) ----
|
|
162
|
-
{
|
|
163
|
-
name: "archive-old-threads",
|
|
164
|
-
asset: "core/scripts/archive-old-threads.sh",
|
|
165
|
-
root: "live",
|
|
166
|
-
summary: "Archive aged session-thread files",
|
|
167
|
-
},
|
|
168
168
|
{
|
|
169
169
|
name: "backfill-company-skill-mirrors",
|
|
170
170
|
asset: "core/scripts/backfill-company-skill-mirrors.sh",
|
|
@@ -177,19 +177,6 @@ export const SCAFFOLD_COMMANDS = [
|
|
|
177
177
|
root: "cwd",
|
|
178
178
|
summary: "One-shot: mirror threads into company workspaces",
|
|
179
179
|
},
|
|
180
|
-
{
|
|
181
|
-
name: "qmd-reindex-after-sync",
|
|
182
|
-
asset: "core/scripts/qmd-reindex-after-sync.sh",
|
|
183
|
-
root: "cwd",
|
|
184
|
-
summary: "Refresh qmd collections after a sync",
|
|
185
|
-
},
|
|
186
|
-
// ---- Reporting and utilities (live tree) ----
|
|
187
|
-
{
|
|
188
|
-
name: "codex-skill-bridge",
|
|
189
|
-
asset: "core/scripts/codex-skill-bridge.sh",
|
|
190
|
-
root: "live",
|
|
191
|
-
summary: "Install/inspect the Codex skill bridge",
|
|
192
|
-
},
|
|
193
180
|
];
|
|
194
181
|
/**
|
|
195
182
|
* Bundled solely for HQ-root scaffolds, which still need these as loose files.
|
|
@@ -197,6 +184,9 @@ export const SCAFFOLD_COMMANDS = [
|
|
|
197
184
|
* direct `hq core` command.
|
|
198
185
|
*/
|
|
199
186
|
export const SCAFFOLD_ONLY_ASSETS = [
|
|
187
|
+
{ asset: "core/scripts/archive-old-threads.sh", root: "live", summary: "Scaffold-only native utility companion" },
|
|
188
|
+
{ asset: "core/scripts/codex-skill-bridge.sh", root: "live", summary: "Scaffold-only native utility companion" },
|
|
189
|
+
{ asset: "core/scripts/qmd-reindex-after-sync.sh", root: "cwd", summary: "Scaffold-only native utility companion" },
|
|
200
190
|
{ asset: "core/scripts/detect-stale-core-policy-mirror.sh", root: "live", summary: "Scaffold-only native utility companion" },
|
|
201
191
|
{ asset: "core/scripts/hq-status-summary.sh", root: "cwd", summary: "Scaffold-only native utility companion" },
|
|
202
192
|
{ asset: "core/scripts/ontology-readme-drift.sh", root: "live", summary: "Scaffold-only native utility companion" },
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type UtilityIo } from "./common.js";
|
|
2
|
+
export type ArchiveOptions = UtilityIo & {
|
|
3
|
+
hqRoot?: string;
|
|
4
|
+
/** Injected for tests; defaults to the wall clock. */
|
|
5
|
+
now?: () => Date;
|
|
6
|
+
};
|
|
7
|
+
export declare function archiveOldThreads(args?: string[], options?: ArchiveOptions): number;
|
|
8
|
+
//# sourceMappingURL=archive-old-threads.d.ts.map
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Native implementation of `core/scripts/archive-old-threads.sh`.
|
|
3
|
+
*
|
|
4
|
+
* Moves `workspace/threads/T-YYYYMMDD-*.json` older than the cutoff into
|
|
5
|
+
* `workspace/threads/archive/YYYY-MM/`. Idempotent: files already under
|
|
6
|
+
* `archive/` are never listed, so repeat runs are no-ops.
|
|
7
|
+
*/
|
|
8
|
+
import * as fs from "node:fs";
|
|
9
|
+
import * as path from "node:path";
|
|
10
|
+
import { ioFor, line } from "./common.js";
|
|
11
|
+
/** `date -v-Nd +%Y%m%d` / `date -d "-N days" +%Y%m%d`, in local time. */
|
|
12
|
+
function cutoffStamp(now, days) {
|
|
13
|
+
const cutoff = new Date(now.getTime());
|
|
14
|
+
cutoff.setDate(cutoff.getDate() - days);
|
|
15
|
+
const year = cutoff.getFullYear();
|
|
16
|
+
const month = `${cutoff.getMonth() + 1}`.padStart(2, "0");
|
|
17
|
+
const day = `${cutoff.getDate()}`.padStart(2, "0");
|
|
18
|
+
return `${year}${month}${day}`;
|
|
19
|
+
}
|
|
20
|
+
export function archiveOldThreads(args = [], options = {}) {
|
|
21
|
+
const { stderr } = ioFor(options);
|
|
22
|
+
const hqRoot = options.hqRoot ?? process.env.HQ_ROOT ?? process.cwd();
|
|
23
|
+
const now = options.now ?? (() => new Date());
|
|
24
|
+
let cutoffDays = 60;
|
|
25
|
+
let dryRun = false;
|
|
26
|
+
let gated = false;
|
|
27
|
+
for (let index = 0; index < args.length; index++) {
|
|
28
|
+
const arg = args[index];
|
|
29
|
+
if (arg === "--days") {
|
|
30
|
+
cutoffDays = Number(args[++index]);
|
|
31
|
+
}
|
|
32
|
+
else if (arg === "--dry-run") {
|
|
33
|
+
dryRun = true;
|
|
34
|
+
}
|
|
35
|
+
else if (arg === "--gated") {
|
|
36
|
+
gated = true;
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
line(stderr, `Unknown arg: ${arg}`);
|
|
40
|
+
return 2;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const touchfile = path.join(hqRoot, "workspace/.last-archive-run");
|
|
44
|
+
if (gated && fs.existsSync(touchfile)) {
|
|
45
|
+
const mtime = Math.floor(fs.statSync(touchfile).mtimeMs / 1000);
|
|
46
|
+
const seconds = Math.floor(now().getTime() / 1000);
|
|
47
|
+
if (seconds - mtime < 86_400) {
|
|
48
|
+
line(stderr, "archive-old-threads: gated (ran <24h ago, skipping)");
|
|
49
|
+
return 0;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const threadsDir = path.join(hqRoot, "workspace/threads");
|
|
53
|
+
const archiveRoot = path.join(threadsDir, "archive");
|
|
54
|
+
fs.mkdirSync(archiveRoot, { recursive: true });
|
|
55
|
+
const cutoff = cutoffStamp(now(), cutoffDays);
|
|
56
|
+
// `ls workspace/threads/T-*.json`: top level only, name-ordered, archive/ excluded.
|
|
57
|
+
let names;
|
|
58
|
+
try {
|
|
59
|
+
names = fs
|
|
60
|
+
.readdirSync(threadsDir)
|
|
61
|
+
.filter((name) => name.startsWith("T-") && name.endsWith(".json"))
|
|
62
|
+
.filter((name) => fs.lstatSync(path.join(threadsDir, name)).isFile())
|
|
63
|
+
.sort();
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
names = [];
|
|
67
|
+
}
|
|
68
|
+
let moved = 0;
|
|
69
|
+
let skipped = 0;
|
|
70
|
+
for (const name of names) {
|
|
71
|
+
const match = name.match(/^T-(\d{8})-/);
|
|
72
|
+
if (!match) {
|
|
73
|
+
skipped += 1;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const datePart = match[1];
|
|
77
|
+
// Both stamps are 8-digit YYYYMMDD, so numeric and lexical order agree.
|
|
78
|
+
if (Number(datePart) >= Number(cutoff))
|
|
79
|
+
continue;
|
|
80
|
+
const bucket = path.join(archiveRoot, `${datePart.slice(0, 4)}-${datePart.slice(4, 6)}`);
|
|
81
|
+
if (dryRun) {
|
|
82
|
+
line(stderr, `WOULD MOVE: ${path.join("workspace/threads", name)} -> ${path.relative(hqRoot, bucket)}/`);
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
fs.mkdirSync(bucket, { recursive: true });
|
|
86
|
+
fs.renameSync(path.join(threadsDir, name), path.join(bucket, name));
|
|
87
|
+
}
|
|
88
|
+
moved += 1;
|
|
89
|
+
}
|
|
90
|
+
if (!dryRun && gated) {
|
|
91
|
+
fs.mkdirSync(path.dirname(touchfile), { recursive: true });
|
|
92
|
+
fs.writeFileSync(touchfile, "");
|
|
93
|
+
}
|
|
94
|
+
line(stderr, `archive-old-threads: cutoff=${cutoffDays}d (before ${cutoff}) moved=${moved} skipped=${skipped} dry_run=${dryRun}`);
|
|
95
|
+
return 0;
|
|
96
|
+
}
|
|
97
|
+
//# sourceMappingURL=archive-old-threads.js.map
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** CLI adapter for the native Codex skill bridge. */
|
|
2
|
+
import { type UtilityIo } from "./common.js";
|
|
3
|
+
export type CodexSkillBridgeOptions = UtilityIo & {
|
|
4
|
+
root?: string;
|
|
5
|
+
home?: string;
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* Mirrors the shell entry point: usage on `--help` (exit 0) or a missing
|
|
9
|
+
* command (exit 1), single-line stderr plus exit 1 for refusals, and the
|
|
10
|
+
* unhealthy-check count as `status`/`install`'s exit code.
|
|
11
|
+
*/
|
|
12
|
+
export declare function runCodexSkillBridgeCommand(args?: string[], options?: CodexSkillBridgeOptions): number;
|
|
13
|
+
//# sourceMappingURL=codex-skill-bridge-entry.d.ts.map
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/** CLI adapter for the native Codex skill bridge. */
|
|
2
|
+
import { ioFor, line } from "./common.js";
|
|
3
|
+
import { BridgeError, BridgeHelp, USAGE, parseArgs, runCodexSkillBridge, } from "./codex-skill-bridge.js";
|
|
4
|
+
/**
|
|
5
|
+
* Mirrors the shell entry point: usage on `--help` (exit 0) or a missing
|
|
6
|
+
* command (exit 1), single-line stderr plus exit 1 for refusals, and the
|
|
7
|
+
* unhealthy-check count as `status`/`install`'s exit code.
|
|
8
|
+
*/
|
|
9
|
+
export function runCodexSkillBridgeCommand(args = [], options = {}) {
|
|
10
|
+
const { stdout, stderr } = ioFor(options);
|
|
11
|
+
const defaultRoot = options.root ?? process.env.HQ_ROOT ?? process.cwd();
|
|
12
|
+
const home = options.home ?? process.env.HOME ?? "";
|
|
13
|
+
let parsed;
|
|
14
|
+
try {
|
|
15
|
+
parsed = parseArgs(args, defaultRoot);
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
if (error instanceof BridgeHelp) {
|
|
19
|
+
line(stdout, USAGE);
|
|
20
|
+
return 0;
|
|
21
|
+
}
|
|
22
|
+
if (error instanceof BridgeError) {
|
|
23
|
+
// An empty message is the shell's "no command given" path: usage, exit 1.
|
|
24
|
+
if (!error.message) {
|
|
25
|
+
line(stdout, USAGE);
|
|
26
|
+
return 1;
|
|
27
|
+
}
|
|
28
|
+
line(stderr, error.message);
|
|
29
|
+
return 1;
|
|
30
|
+
}
|
|
31
|
+
throw error;
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
return runCodexSkillBridge(parsed, {
|
|
35
|
+
home,
|
|
36
|
+
out: (text) => line(stdout, text),
|
|
37
|
+
err: (text) => line(stderr, text),
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
if (error instanceof BridgeError) {
|
|
42
|
+
line(stderr, error.message);
|
|
43
|
+
return 1;
|
|
44
|
+
}
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
//# sourceMappingURL=codex-skill-bridge-entry.js.map
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export type BridgeCommand = "status" | "install" | "doctor";
|
|
2
|
+
export type BridgeOptions = {
|
|
3
|
+
command: BridgeCommand;
|
|
4
|
+
hqRoot: string;
|
|
5
|
+
oldRoots: string[];
|
|
6
|
+
repairLocalConfig: boolean;
|
|
7
|
+
dryRun: boolean;
|
|
8
|
+
};
|
|
9
|
+
export type BridgeIo = {
|
|
10
|
+
home: string;
|
|
11
|
+
out: (line: string) => void;
|
|
12
|
+
err: (line: string) => void;
|
|
13
|
+
};
|
|
14
|
+
/** Thrown for the shell's `fail`/refusal paths: message to stderr, exit 1. */
|
|
15
|
+
export declare class BridgeError extends Error {
|
|
16
|
+
name: string;
|
|
17
|
+
}
|
|
18
|
+
export declare const USAGE = "Usage:\n scripts/codex-skill-bridge.sh status [--root <path>]\n scripts/codex-skill-bridge.sh install [--root <path>] [--repair-local-config] [--old-root <path>]\n scripts/codex-skill-bridge.sh doctor [--root <path>] [--old-root <path>] [--dry-run]\n\nCommands:\n status Show bridge health, output-style bridge health, and stale HQ roots without changing files.\n install Install or repair HQ-owned Claude -> Codex bridges.\n doctor Repair bridges and rewrite local machine config from old HQ roots to this root.\n\nOptions:\n --root <path> HQ root to repair. Defaults to this script's parent.\n --old-root <path> Old HQ root to rewrite. Repeatable. Auto-detected when omitted.\n --repair-local-config With install, also rewrite local config paths.\n --dry-run With doctor, print intended config rewrites without changing files.";
|
|
19
|
+
/** Signals `--help`: caller prints usage and exits 0. */
|
|
20
|
+
export declare class BridgeHelp extends Error {
|
|
21
|
+
}
|
|
22
|
+
export declare function parseArgs(argv: string[], defaultRoot: string): BridgeOptions;
|
|
23
|
+
/** `strip_known_hq_subpath`: returns undefined when no known suffix matches. */
|
|
24
|
+
export declare function stripKnownHqSubpath(target: string): string | undefined;
|
|
25
|
+
/** `is_hq_root_like`: basename HQ, no brace placeholders, not the bare path. */
|
|
26
|
+
export declare function isHqRootLike(candidate: string): boolean;
|
|
27
|
+
/** `output_style_slug`: lowercase, collapse space/underscore, strip oddities. */
|
|
28
|
+
export declare function outputStyleSlug(name: string): string;
|
|
29
|
+
/** Runs one bridge command; returns the process exit code. */
|
|
30
|
+
export declare function runCodexSkillBridge(options: BridgeOptions, io: BridgeIo): number;
|
|
31
|
+
//# sourceMappingURL=codex-skill-bridge.d.ts.map
|
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Native port of `core/scripts/codex-skill-bridge.sh`.
|
|
3
|
+
*
|
|
4
|
+
* The bridge symlinks HQ's `.claude` sources into the places Codex and the
|
|
5
|
+
* agents runtime look for them, and repairs links (and optionally local config
|
|
6
|
+
* paths) left pointing at an HQ root that has since moved.
|
|
7
|
+
*
|
|
8
|
+
* Every path derives from the resolved HQ root and `HOME`, so callers — and
|
|
9
|
+
* the differential suite — can exercise the whole surface hermetically.
|
|
10
|
+
*/
|
|
11
|
+
import * as fs from "node:fs";
|
|
12
|
+
import * as path from "node:path";
|
|
13
|
+
/** Thrown for the shell's `fail`/refusal paths: message to stderr, exit 1. */
|
|
14
|
+
export class BridgeError extends Error {
|
|
15
|
+
name = "BridgeError";
|
|
16
|
+
}
|
|
17
|
+
export const USAGE = `Usage:
|
|
18
|
+
scripts/codex-skill-bridge.sh status [--root <path>]
|
|
19
|
+
scripts/codex-skill-bridge.sh install [--root <path>] [--repair-local-config] [--old-root <path>]
|
|
20
|
+
scripts/codex-skill-bridge.sh doctor [--root <path>] [--old-root <path>] [--dry-run]
|
|
21
|
+
|
|
22
|
+
Commands:
|
|
23
|
+
status Show bridge health, output-style bridge health, and stale HQ roots without changing files.
|
|
24
|
+
install Install or repair HQ-owned Claude -> Codex bridges.
|
|
25
|
+
doctor Repair bridges and rewrite local machine config from old HQ roots to this root.
|
|
26
|
+
|
|
27
|
+
Options:
|
|
28
|
+
--root <path> HQ root to repair. Defaults to this script's parent.
|
|
29
|
+
--old-root <path> Old HQ root to rewrite. Repeatable. Auto-detected when omitted.
|
|
30
|
+
--repair-local-config With install, also rewrite local config paths.
|
|
31
|
+
--dry-run With doctor, print intended config rewrites without changing files.`;
|
|
32
|
+
/** Signals `--help`: caller prints usage and exits 0. */
|
|
33
|
+
export class BridgeHelp extends Error {
|
|
34
|
+
}
|
|
35
|
+
export function parseArgs(argv, defaultRoot) {
|
|
36
|
+
let command = "";
|
|
37
|
+
let hqRoot = defaultRoot;
|
|
38
|
+
const oldRoots = [];
|
|
39
|
+
let repairLocalConfig = false;
|
|
40
|
+
let dryRun = false;
|
|
41
|
+
for (let index = 0; index < argv.length; index++) {
|
|
42
|
+
const arg = argv[index];
|
|
43
|
+
if (arg === "status" || arg === "install" || arg === "doctor") {
|
|
44
|
+
if (command)
|
|
45
|
+
throw new BridgeError("Only one command may be provided.");
|
|
46
|
+
command = arg;
|
|
47
|
+
}
|
|
48
|
+
else if (arg === "--root") {
|
|
49
|
+
if (index + 1 >= argv.length)
|
|
50
|
+
throw new BridgeError("--root requires a path.");
|
|
51
|
+
hqRoot = argv[++index];
|
|
52
|
+
}
|
|
53
|
+
else if (arg.startsWith("--root=")) {
|
|
54
|
+
hqRoot = arg.slice("--root=".length);
|
|
55
|
+
}
|
|
56
|
+
else if (arg === "--old-root") {
|
|
57
|
+
if (index + 1 >= argv.length)
|
|
58
|
+
throw new BridgeError("--old-root requires a path.");
|
|
59
|
+
oldRoots.push(argv[++index]);
|
|
60
|
+
}
|
|
61
|
+
else if (arg.startsWith("--old-root=")) {
|
|
62
|
+
oldRoots.push(arg.slice("--old-root=".length));
|
|
63
|
+
}
|
|
64
|
+
else if (arg === "--repair-local-config") {
|
|
65
|
+
repairLocalConfig = true;
|
|
66
|
+
}
|
|
67
|
+
else if (arg === "--dry-run") {
|
|
68
|
+
dryRun = true;
|
|
69
|
+
}
|
|
70
|
+
else if (arg === "-h" || arg === "--help") {
|
|
71
|
+
throw new BridgeHelp();
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
throw new BridgeError(`Unknown argument: ${arg}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (!command)
|
|
78
|
+
throw new BridgeError("");
|
|
79
|
+
return { command, hqRoot, oldRoots, repairLocalConfig, dryRun };
|
|
80
|
+
}
|
|
81
|
+
/** `canonical_dir`: the shell resolves via `cd … && pwd`, so symlinks collapse. */
|
|
82
|
+
function canonicalDir(directory) {
|
|
83
|
+
return fs.realpathSync(directory);
|
|
84
|
+
}
|
|
85
|
+
function configurePaths(hqRootInput, home) {
|
|
86
|
+
const hqRoot = canonicalDir(hqRootInput);
|
|
87
|
+
const claudeSource = path.join(hqRoot, ".claude");
|
|
88
|
+
const projectCodexDir = path.join(hqRoot, ".codex");
|
|
89
|
+
const legacyCodexDir = path.join(hqRoot, ".Codex");
|
|
90
|
+
return {
|
|
91
|
+
hqRoot,
|
|
92
|
+
skillsSource: path.join(claudeSource, "skills"),
|
|
93
|
+
claudeSource,
|
|
94
|
+
commandsSource: path.join(claudeSource, "commands"),
|
|
95
|
+
hooksSource: path.join(claudeSource, "hooks"),
|
|
96
|
+
policiesSource: path.join(claudeSource, "policies"),
|
|
97
|
+
settingsFile: path.join(claudeSource, "settings.json"),
|
|
98
|
+
outputStylesSource: path.join(claudeSource, "output-styles"),
|
|
99
|
+
globalSkillsTarget: path.join(home, ".codex", "skills", "hq"),
|
|
100
|
+
globalAgentsSkillsTarget: path.join(home, ".agents", "skills", "hq"),
|
|
101
|
+
repoAgentsSkillsTarget: path.join(hqRoot, ".agents", "skills"),
|
|
102
|
+
projectCodexDir,
|
|
103
|
+
projectClaudeTarget: path.join(projectCodexDir, "claude"),
|
|
104
|
+
projectPromptsTarget: path.join(projectCodexDir, "prompts"),
|
|
105
|
+
projectOutputStyleTarget: path.join(projectCodexDir, "output-style.md"),
|
|
106
|
+
legacyCodexDir,
|
|
107
|
+
legacyClaudeTarget: path.join(legacyCodexDir, "claude"),
|
|
108
|
+
legacyPromptsTarget: path.join(legacyCodexDir, "prompts"),
|
|
109
|
+
legacyOutputStyleTarget: path.join(legacyCodexDir, "output-style.md"),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
function isSymlink(target) {
|
|
113
|
+
try {
|
|
114
|
+
return fs.lstatSync(target).isSymbolicLink();
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/** `[[ -e ]]`: follows links, so a dangling symlink is not "exists". */
|
|
121
|
+
function exists(target) {
|
|
122
|
+
return fs.existsSync(target);
|
|
123
|
+
}
|
|
124
|
+
function listEntries(directory) {
|
|
125
|
+
try {
|
|
126
|
+
return fs.readdirSync(directory);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return [];
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/** Directory or symlink child holding a SKILL.md, matching the shell's find. */
|
|
133
|
+
function skillDirectories(skillsSource) {
|
|
134
|
+
return listEntries(skillsSource).filter((name) => {
|
|
135
|
+
const child = path.join(skillsSource, name);
|
|
136
|
+
let isDirOrLink = false;
|
|
137
|
+
try {
|
|
138
|
+
const stat = fs.lstatSync(child);
|
|
139
|
+
isDirOrLink = stat.isDirectory() || stat.isSymbolicLink();
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
return isDirOrLink && fs.existsSync(path.join(child, "SKILL.md"));
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
function markdownFiles(directory) {
|
|
148
|
+
return listEntries(directory).filter((name) => {
|
|
149
|
+
if (!name.endsWith(".md"))
|
|
150
|
+
return false;
|
|
151
|
+
try {
|
|
152
|
+
return fs.lstatSync(path.join(directory, name)).isFile();
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
function plainFiles(directory) {
|
|
160
|
+
return listEntries(directory).filter((name) => {
|
|
161
|
+
try {
|
|
162
|
+
return fs.lstatSync(path.join(directory, name)).isFile();
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* `normalize_link_target`: absolute targets pass through; relative ones resolve
|
|
171
|
+
* against the link's directory, with existing paths canonicalised.
|
|
172
|
+
*/
|
|
173
|
+
function normalizeLinkTarget(target) {
|
|
174
|
+
const linkTarget = fs.readlinkSync(target);
|
|
175
|
+
if (path.isAbsolute(linkTarget))
|
|
176
|
+
return linkTarget;
|
|
177
|
+
const targetDir = canonicalDir(path.dirname(target));
|
|
178
|
+
const resolved = path.join(targetDir, linkTarget);
|
|
179
|
+
if (fs.existsSync(resolved)) {
|
|
180
|
+
if (fs.statSync(resolved).isDirectory())
|
|
181
|
+
return canonicalDir(resolved);
|
|
182
|
+
return path.join(canonicalDir(path.dirname(resolved)), path.basename(resolved));
|
|
183
|
+
}
|
|
184
|
+
return resolved;
|
|
185
|
+
}
|
|
186
|
+
/** `strip_known_hq_subpath`: returns undefined when no known suffix matches. */
|
|
187
|
+
export function stripKnownHqSubpath(target) {
|
|
188
|
+
const outputStyle = target.match(/^(.*)\/\.claude\/output-styles\/[^/]*\.md$/);
|
|
189
|
+
if (outputStyle)
|
|
190
|
+
return outputStyle[1];
|
|
191
|
+
for (const suffix of ["/.claude/skills", "/.claude/commands", "/.claude"]) {
|
|
192
|
+
if (target.endsWith(suffix))
|
|
193
|
+
return target.slice(0, -suffix.length);
|
|
194
|
+
}
|
|
195
|
+
return undefined;
|
|
196
|
+
}
|
|
197
|
+
/** `is_hq_root_like`: basename HQ, no brace placeholders, not the bare path. */
|
|
198
|
+
export function isHqRootLike(candidate) {
|
|
199
|
+
if (candidate.includes("{") || candidate.includes("}"))
|
|
200
|
+
return false;
|
|
201
|
+
if (candidate === "/Documents/HQ")
|
|
202
|
+
return false;
|
|
203
|
+
return path.basename(candidate) === "HQ";
|
|
204
|
+
}
|
|
205
|
+
function activeOutputStyleName(paths) {
|
|
206
|
+
let contents;
|
|
207
|
+
try {
|
|
208
|
+
contents = fs.readFileSync(paths.settingsFile, "utf8");
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
return undefined;
|
|
212
|
+
}
|
|
213
|
+
// The shell greps line-wise and keeps the LAST match.
|
|
214
|
+
let match;
|
|
215
|
+
for (const line of contents.split("\n")) {
|
|
216
|
+
const found = line.match(/"outputStyle"[ \t]*:[ \t]*"([^"]*)"/);
|
|
217
|
+
if (found)
|
|
218
|
+
match = found[1];
|
|
219
|
+
}
|
|
220
|
+
return match;
|
|
221
|
+
}
|
|
222
|
+
/** `output_style_slug`: lowercase, collapse space/underscore, strip oddities. */
|
|
223
|
+
export function outputStyleSlug(name) {
|
|
224
|
+
return name
|
|
225
|
+
.toLowerCase()
|
|
226
|
+
.replace(/[ \t\n_]+/g, "-")
|
|
227
|
+
.replace(/[^a-z0-9.-]/g, "-")
|
|
228
|
+
.replace(/-+/g, "-")
|
|
229
|
+
.replace(/^-/, "")
|
|
230
|
+
.replace(/-$/, "");
|
|
231
|
+
}
|
|
232
|
+
function resolveOutputStyleSourceFile(paths) {
|
|
233
|
+
const styleName = activeOutputStyleName(paths);
|
|
234
|
+
if (!styleName)
|
|
235
|
+
return undefined;
|
|
236
|
+
const source = path.join(paths.outputStylesSource, `${outputStyleSlug(styleName)}.md`);
|
|
237
|
+
try {
|
|
238
|
+
if (!fs.lstatSync(source).isFile())
|
|
239
|
+
return undefined;
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
return undefined;
|
|
243
|
+
}
|
|
244
|
+
return source;
|
|
245
|
+
}
|
|
246
|
+
/** `bridge_target_is_repairable`: same suffix under a different HQ-like root. */
|
|
247
|
+
function bridgeTargetIsRepairable(currentTarget, source, hqRoot) {
|
|
248
|
+
const expectedSuffix = source.startsWith(hqRoot) ? source.slice(hqRoot.length) : source;
|
|
249
|
+
const targetRoot = stripKnownHqSubpath(currentTarget);
|
|
250
|
+
if (!targetRoot)
|
|
251
|
+
return false;
|
|
252
|
+
if (!isHqRootLike(targetRoot))
|
|
253
|
+
return false;
|
|
254
|
+
if (targetRoot === hqRoot)
|
|
255
|
+
return false;
|
|
256
|
+
return currentTarget.slice(targetRoot.length) === expectedSuffix;
|
|
257
|
+
}
|
|
258
|
+
function printLinkStatus(io, state, paths, label, source, target) {
|
|
259
|
+
io.out(`${label}:`);
|
|
260
|
+
io.out(` source: ${source}`);
|
|
261
|
+
io.out(` target: ${target}`);
|
|
262
|
+
if (isSymlink(target)) {
|
|
263
|
+
const resolvedTarget = normalizeLinkTarget(target);
|
|
264
|
+
io.out(" bridge: installed");
|
|
265
|
+
io.out(` points to: ${resolvedTarget}`);
|
|
266
|
+
if (resolvedTarget === source) {
|
|
267
|
+
io.out(" status: healthy");
|
|
268
|
+
}
|
|
269
|
+
else if (bridgeTargetIsRepairable(resolvedTarget, source, paths.hqRoot)) {
|
|
270
|
+
io.out(" status: stale HQ root (repairable)");
|
|
271
|
+
state.failures += 1;
|
|
272
|
+
}
|
|
273
|
+
else {
|
|
274
|
+
io.out(" status: unexpected target");
|
|
275
|
+
state.failures += 1;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
else if (exists(target)) {
|
|
279
|
+
io.out(" bridge: blocked");
|
|
280
|
+
io.out(" status: target exists and is not a symlink");
|
|
281
|
+
state.failures += 1;
|
|
282
|
+
}
|
|
283
|
+
else {
|
|
284
|
+
io.out(" bridge: not installed");
|
|
285
|
+
state.failures += 1;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
function repairOrCreateSymlink(io, options, paths, label, source, target) {
|
|
289
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
290
|
+
if (isSymlink(target)) {
|
|
291
|
+
const resolvedTarget = normalizeLinkTarget(target);
|
|
292
|
+
if (resolvedTarget === source) {
|
|
293
|
+
io.out(`${label} already installed.`);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
if (bridgeTargetIsRepairable(resolvedTarget, source, paths.hqRoot)) {
|
|
297
|
+
if (options.dryRun) {
|
|
298
|
+
io.out(`Would repair ${label}: ${resolvedTarget} -> ${source}`);
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
fs.unlinkSync(target);
|
|
302
|
+
fs.symlinkSync(source, target);
|
|
303
|
+
io.out(`Repaired ${label}: ${resolvedTarget} -> ${source}`);
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
throw new BridgeError(`Refusing to replace existing symlink: ${target} -> ${resolvedTarget}`);
|
|
307
|
+
}
|
|
308
|
+
if (exists(target)) {
|
|
309
|
+
throw new BridgeError(`Refusing to overwrite existing path: ${target}`);
|
|
310
|
+
}
|
|
311
|
+
if (options.dryRun) {
|
|
312
|
+
io.out(`Would install ${label}: ${target} -> ${source}`);
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
fs.symlinkSync(source, target);
|
|
316
|
+
io.out(`Installed ${label}.`);
|
|
317
|
+
}
|
|
318
|
+
function configFiles(paths) {
|
|
319
|
+
return [
|
|
320
|
+
path.join(paths.hqRoot, ".mcp.json"),
|
|
321
|
+
path.join(paths.claudeSource, "settings.json"),
|
|
322
|
+
path.join(paths.claudeSource, "settings.local.json"),
|
|
323
|
+
path.join(paths.projectCodexDir, "config.toml"),
|
|
324
|
+
path.join(paths.legacyCodexDir, "config.toml"),
|
|
325
|
+
].filter((file) => {
|
|
326
|
+
try {
|
|
327
|
+
return fs.statSync(file).isFile();
|
|
328
|
+
}
|
|
329
|
+
catch {
|
|
330
|
+
return false;
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
/** `extract_hq_roots_from_file`: absolute paths ending in `/HQ` or `/HQ/`. */
|
|
335
|
+
function extractHqRootsFromFile(file) {
|
|
336
|
+
let contents;
|
|
337
|
+
try {
|
|
338
|
+
contents = fs.readFileSync(file, "utf8");
|
|
339
|
+
}
|
|
340
|
+
catch {
|
|
341
|
+
return [];
|
|
342
|
+
}
|
|
343
|
+
const matches = contents.match(/\/[^"\s]+\/HQ(\/|$)/gm) ?? [];
|
|
344
|
+
return matches.map((match) => (match.endsWith("/") ? match.slice(0, -1) : match));
|
|
345
|
+
}
|
|
346
|
+
function collectStaleRoots(paths, options, includeExplicit) {
|
|
347
|
+
const roots = [];
|
|
348
|
+
if (includeExplicit) {
|
|
349
|
+
for (const root of options.oldRoots)
|
|
350
|
+
if (root)
|
|
351
|
+
roots.push(root);
|
|
352
|
+
}
|
|
353
|
+
for (const target of [
|
|
354
|
+
paths.globalSkillsTarget,
|
|
355
|
+
paths.globalAgentsSkillsTarget,
|
|
356
|
+
paths.repoAgentsSkillsTarget,
|
|
357
|
+
paths.projectClaudeTarget,
|
|
358
|
+
paths.projectPromptsTarget,
|
|
359
|
+
paths.projectOutputStyleTarget,
|
|
360
|
+
paths.legacyClaudeTarget,
|
|
361
|
+
paths.legacyPromptsTarget,
|
|
362
|
+
paths.legacyOutputStyleTarget,
|
|
363
|
+
]) {
|
|
364
|
+
if (!isSymlink(target))
|
|
365
|
+
continue;
|
|
366
|
+
const stripped = stripKnownHqSubpath(normalizeLinkTarget(target));
|
|
367
|
+
if (stripped)
|
|
368
|
+
roots.push(stripped);
|
|
369
|
+
}
|
|
370
|
+
for (const file of configFiles(paths))
|
|
371
|
+
roots.push(...extractHqRootsFromFile(file));
|
|
372
|
+
// `sort -u` then filter, matching the shell's ordering exactly.
|
|
373
|
+
return [...new Set(roots)]
|
|
374
|
+
.sort()
|
|
375
|
+
.filter((root) => root && root !== paths.hqRoot && isHqRootLike(root));
|
|
376
|
+
}
|
|
377
|
+
function printStaleRootReport(io, paths, options) {
|
|
378
|
+
const roots = collectStaleRoots(paths, options, false);
|
|
379
|
+
if (roots.length === 0) {
|
|
380
|
+
io.out("Stale HQ roots: none detected");
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
io.out("Stale HQ roots detected:");
|
|
384
|
+
for (const root of roots)
|
|
385
|
+
io.out(` - ${root}`);
|
|
386
|
+
}
|
|
387
|
+
function rewriteLocalConfigRoots(io, paths, options) {
|
|
388
|
+
const roots = collectStaleRoots(paths, options, true);
|
|
389
|
+
if (roots.length === 0) {
|
|
390
|
+
io.out("No stale local config roots to rewrite.");
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
let rewroteAny = false;
|
|
394
|
+
for (const file of configFiles(paths)) {
|
|
395
|
+
let changedFile = false;
|
|
396
|
+
for (const oldRoot of roots) {
|
|
397
|
+
if (!oldRoot)
|
|
398
|
+
continue;
|
|
399
|
+
let contents = fs.readFileSync(file, "utf8");
|
|
400
|
+
if (!contents.includes(oldRoot))
|
|
401
|
+
continue;
|
|
402
|
+
if (options.dryRun) {
|
|
403
|
+
io.out(`Would rewrite ${file}: ${oldRoot} -> ${paths.hqRoot}`);
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
contents = contents.split(oldRoot).join(paths.hqRoot);
|
|
407
|
+
fs.writeFileSync(file, contents);
|
|
408
|
+
changedFile = true;
|
|
409
|
+
}
|
|
410
|
+
if (changedFile) {
|
|
411
|
+
io.out(`Rewrote local config paths in ${file}.`);
|
|
412
|
+
rewroteAny = true;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
if (!rewroteAny && !options.dryRun)
|
|
416
|
+
io.out("No local config files needed rewriting.");
|
|
417
|
+
}
|
|
418
|
+
function printOutputStyleStatus(io, state, paths) {
|
|
419
|
+
const styleName = activeOutputStyleName(paths);
|
|
420
|
+
io.out(`Active output style: ${styleName || "not configured"}`);
|
|
421
|
+
const source = resolveOutputStyleSourceFile(paths);
|
|
422
|
+
if (source) {
|
|
423
|
+
printLinkStatus(io, state, paths, "Project Codex output-style bridge", source, paths.projectOutputStyleTarget);
|
|
424
|
+
if (exists(paths.legacyCodexDir) || isSymlink(paths.legacyOutputStyleTarget)) {
|
|
425
|
+
io.out("");
|
|
426
|
+
printLinkStatus(io, state, paths, "Legacy .Codex output-style bridge", source, paths.legacyOutputStyleTarget);
|
|
427
|
+
}
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
io.out("Project Codex output-style bridge:");
|
|
431
|
+
io.out(" source: unavailable");
|
|
432
|
+
io.out(` target: ${paths.projectOutputStyleTarget}`);
|
|
433
|
+
io.out(" status: active output style has no .claude/output-styles/*.md bridge source");
|
|
434
|
+
state.failures += 1;
|
|
435
|
+
}
|
|
436
|
+
/** Returns the shell's status exit code: the number of unhealthy checks. */
|
|
437
|
+
function printStatus(io, paths, options) {
|
|
438
|
+
const skillTotal = skillDirectories(paths.skillsSource).length;
|
|
439
|
+
const skillWith = skillDirectories(paths.skillsSource).filter((name) => fs.existsSync(path.join(paths.skillsSource, name, "agents", "openai.yaml"))).length;
|
|
440
|
+
const state = { failures: 0 };
|
|
441
|
+
io.out(`HQ Claude source: ${paths.claudeSource}`);
|
|
442
|
+
io.out(`Skills in source: ${skillTotal} (${skillWith} with agents/openai.yaml, ${skillTotal - skillWith} without)`);
|
|
443
|
+
io.out(`Hooks in source: ${plainFiles(paths.hooksSource).length}`);
|
|
444
|
+
io.out(`Policies in source: ${markdownFiles(paths.policiesSource).length}`);
|
|
445
|
+
io.out("");
|
|
446
|
+
printLinkStatus(io, state, paths, "Global skills bridge (legacy)", paths.skillsSource, paths.globalSkillsTarget);
|
|
447
|
+
io.out("");
|
|
448
|
+
printLinkStatus(io, state, paths, "Global agents skills bridge", paths.skillsSource, paths.globalAgentsSkillsTarget);
|
|
449
|
+
io.out("");
|
|
450
|
+
printLinkStatus(io, state, paths, "Repo agents skills bridge", paths.skillsSource, paths.repoAgentsSkillsTarget);
|
|
451
|
+
io.out("");
|
|
452
|
+
printLinkStatus(io, state, paths, "Project Claude mirror", paths.claudeSource, paths.projectClaudeTarget);
|
|
453
|
+
io.out("");
|
|
454
|
+
printOutputStyleStatus(io, state, paths);
|
|
455
|
+
if (exists(paths.legacyCodexDir) ||
|
|
456
|
+
isSymlink(paths.legacyClaudeTarget) ||
|
|
457
|
+
isSymlink(paths.legacyPromptsTarget)) {
|
|
458
|
+
io.out("");
|
|
459
|
+
printLinkStatus(io, state, paths, "Legacy .Codex Claude mirror", paths.claudeSource, paths.legacyClaudeTarget);
|
|
460
|
+
}
|
|
461
|
+
io.out("");
|
|
462
|
+
printStaleRootReport(io, paths, options);
|
|
463
|
+
return state.failures;
|
|
464
|
+
}
|
|
465
|
+
function validateSources(paths) {
|
|
466
|
+
if (!fs.existsSync(paths.skillsSource) || !fs.statSync(paths.skillsSource).isDirectory()) {
|
|
467
|
+
throw new BridgeError(`Missing skills source directory: ${paths.skillsSource}`);
|
|
468
|
+
}
|
|
469
|
+
if (!fs.existsSync(paths.claudeSource) || !fs.statSync(paths.claudeSource).isDirectory()) {
|
|
470
|
+
throw new BridgeError(`Missing Claude source directory: ${paths.claudeSource}`);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
function installBridge(io, paths, options) {
|
|
474
|
+
validateSources(paths);
|
|
475
|
+
const outputStyleSource = resolveOutputStyleSourceFile(paths);
|
|
476
|
+
if (!outputStyleSource) {
|
|
477
|
+
throw new BridgeError(`Active outputStyle must have a matching ${paths.outputStylesSource}/<style>.md file.`);
|
|
478
|
+
}
|
|
479
|
+
repairOrCreateSymlink(io, options, paths, "global Codex skill bridge (legacy)", paths.skillsSource, paths.globalSkillsTarget);
|
|
480
|
+
repairOrCreateSymlink(io, options, paths, "global agents skill bridge", paths.skillsSource, paths.globalAgentsSkillsTarget);
|
|
481
|
+
repairOrCreateSymlink(io, options, paths, "repo agents skill bridge", paths.skillsSource, paths.repoAgentsSkillsTarget);
|
|
482
|
+
repairOrCreateSymlink(io, options, paths, "project Codex Claude mirror", paths.claudeSource, paths.projectClaudeTarget);
|
|
483
|
+
repairOrCreateSymlink(io, options, paths, "project Codex output-style bridge", outputStyleSource, paths.projectOutputStyleTarget);
|
|
484
|
+
if (exists(paths.legacyCodexDir) ||
|
|
485
|
+
isSymlink(paths.legacyClaudeTarget) ||
|
|
486
|
+
isSymlink(paths.legacyPromptsTarget)) {
|
|
487
|
+
repairOrCreateSymlink(io, options, paths, "legacy .Codex Claude mirror", paths.claudeSource, paths.legacyClaudeTarget);
|
|
488
|
+
repairOrCreateSymlink(io, options, paths, "legacy .Codex output-style bridge", outputStyleSource, paths.legacyOutputStyleTarget);
|
|
489
|
+
}
|
|
490
|
+
if (options.repairLocalConfig) {
|
|
491
|
+
io.out("");
|
|
492
|
+
rewriteLocalConfigRoots(io, paths, options);
|
|
493
|
+
}
|
|
494
|
+
io.out("");
|
|
495
|
+
return printStatus(io, paths, options);
|
|
496
|
+
}
|
|
497
|
+
/** Runs one bridge command; returns the process exit code. */
|
|
498
|
+
export function runCodexSkillBridge(options, io) {
|
|
499
|
+
const paths = configurePaths(options.hqRoot, io.home);
|
|
500
|
+
if (options.command === "status")
|
|
501
|
+
return printStatus(io, paths, options);
|
|
502
|
+
const effective = options.command === "doctor" ? { ...options, repairLocalConfig: true } : options;
|
|
503
|
+
return installBridge(io, paths, effective);
|
|
504
|
+
}
|
|
505
|
+
//# sourceMappingURL=codex-skill-bridge.js.map
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { UtilityIo } from "./common.js";
|
|
2
|
+
export type QmdReindexOptions = UtilityIo & {
|
|
3
|
+
cwd?: string;
|
|
4
|
+
/** Test seams; default to the real search-index implementations. */
|
|
5
|
+
resolveBin?: () => string;
|
|
6
|
+
reconcile?: (hqRoot: string, options: {
|
|
7
|
+
bin: string;
|
|
8
|
+
}) => unknown;
|
|
9
|
+
run?: (args: string[], options: {
|
|
10
|
+
bin: string;
|
|
11
|
+
cwd: string;
|
|
12
|
+
}) => unknown;
|
|
13
|
+
};
|
|
14
|
+
export declare function qmdReindexAfterSync(args?: string[], options?: QmdReindexOptions): number;
|
|
15
|
+
//# sourceMappingURL=qmd-reindex-after-sync.d.ts.map
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Native implementation of `core/scripts/qmd-reindex-after-sync.sh`.
|
|
3
|
+
*
|
|
4
|
+
* The shell script restated the collection-registration convention that
|
|
5
|
+
* `src/lib/search-index` already owns; this port reuses that single source
|
|
6
|
+
* instead, so the two can no longer drift.
|
|
7
|
+
*
|
|
8
|
+
* Contract preserved verbatim: a missing qmd or a non-HQ path is a silent
|
|
9
|
+
* success (a sync must never fail because indexing could not run), the lexical
|
|
10
|
+
* update always runs, and embeddings rebuild only on `--embed`.
|
|
11
|
+
*/
|
|
12
|
+
import * as fs from "node:fs";
|
|
13
|
+
import * as path from "node:path";
|
|
14
|
+
import { reconcileCollections, resolveQmdBin, runQmd } from "../search-index/index.js";
|
|
15
|
+
export function qmdReindexAfterSync(args = [], options = {}) {
|
|
16
|
+
let hqRoot = "";
|
|
17
|
+
let embed = false;
|
|
18
|
+
for (const arg of args) {
|
|
19
|
+
if (arg === "--embed")
|
|
20
|
+
embed = true;
|
|
21
|
+
else if (arg.startsWith("--"))
|
|
22
|
+
continue; // unknown flags are ignored, as in the shell
|
|
23
|
+
else if (!hqRoot)
|
|
24
|
+
hqRoot = arg;
|
|
25
|
+
}
|
|
26
|
+
if (!hqRoot)
|
|
27
|
+
hqRoot = options.cwd ?? process.cwd();
|
|
28
|
+
let bin;
|
|
29
|
+
try {
|
|
30
|
+
bin = (options.resolveBin ?? resolveQmdBin)();
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return 0; // qmd absent: silent no-op.
|
|
34
|
+
}
|
|
35
|
+
if (!fs.existsSync(path.join(hqRoot, "core/core.yaml")))
|
|
36
|
+
return 0;
|
|
37
|
+
const reconcile = options.reconcile ?? ((root, opts) => reconcileCollections(root, opts));
|
|
38
|
+
const run = options.run ?? ((argv, opts) => runQmd(argv, opts));
|
|
39
|
+
// Every qmd interaction is best-effort: the shell suffixed each with `|| true`.
|
|
40
|
+
try {
|
|
41
|
+
reconcile(hqRoot, { bin });
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
/* registration is advisory; an update still helps */
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
run(["update"], { bin, cwd: hqRoot });
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
/* never fail a sync on index freshness */
|
|
51
|
+
}
|
|
52
|
+
if (embed) {
|
|
53
|
+
try {
|
|
54
|
+
run(["embed"], { bin, cwd: hqRoot });
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
/* embeddings are deferred-cost and optional */
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return 0;
|
|
61
|
+
}
|
|
62
|
+
//# sourceMappingURL=qmd-reindex-after-sync.js.map
|