portable-agent-layer 0.63.1 → 0.63.3
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/assets/templates/hooks.vscode.json +37 -0
- package/package.json +1 -1
- package/src/cli/index.ts +57 -25
- package/src/hooks/SecurityValidator.ts +83 -32
- package/src/hooks/SkillGuard.ts +8 -12
- package/src/hooks/StopOrchestrator.ts +7 -4
- package/src/hooks/handlers/inject-retrieval.ts +21 -17
- package/src/hooks/lib/agent.ts +112 -8
- package/src/hooks/lib/security.ts +90 -0
- package/src/hooks/lib/transcript.ts +37 -18
- package/src/targets/claude/install.ts +3 -21
- package/src/targets/codex/install.ts +2 -17
- package/src/targets/copilot/install.ts +13 -32
- package/src/targets/copilot/uninstall.ts +13 -7
- package/src/targets/cursor/install.ts +2 -30
- package/src/targets/lib.ts +34 -17
- package/src/targets/opencode/install.ts +4 -31
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"hooks": {
|
|
4
|
+
"SessionStart": [
|
|
5
|
+
{
|
|
6
|
+
"type": "command",
|
|
7
|
+
"command": "bun run {{PKG_ROOT}}/src/hooks/LoadContext.ts --agent=vscode"
|
|
8
|
+
}
|
|
9
|
+
],
|
|
10
|
+
"UserPromptSubmit": [
|
|
11
|
+
{
|
|
12
|
+
"type": "command",
|
|
13
|
+
"command": "bun run {{PKG_ROOT}}/src/hooks/UserPromptOrchestrator.ts --agent=vscode"
|
|
14
|
+
}
|
|
15
|
+
],
|
|
16
|
+
"PreToolUse": [
|
|
17
|
+
{
|
|
18
|
+
"type": "command",
|
|
19
|
+
"command": "bun run {{PKG_ROOT}}/src/hooks/SecurityValidator.ts --agent=vscode"
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"type": "command",
|
|
23
|
+
"command": "bun run {{PKG_ROOT}}/src/hooks/SkillGuard.ts --agent=vscode"
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"type": "command",
|
|
27
|
+
"command": "bun run {{PKG_ROOT}}/src/hooks/RtkWrap.ts --agent=vscode"
|
|
28
|
+
}
|
|
29
|
+
],
|
|
30
|
+
"Stop": [
|
|
31
|
+
{
|
|
32
|
+
"type": "command",
|
|
33
|
+
"command": "bun run {{PKG_ROOT}}/src/hooks/StopOrchestrator.ts --agent=vscode"
|
|
34
|
+
}
|
|
35
|
+
]
|
|
36
|
+
}
|
|
37
|
+
}
|
package/package.json
CHANGED
package/src/cli/index.ts
CHANGED
|
@@ -37,7 +37,7 @@ import { homedir } from "node:os";
|
|
|
37
37
|
import { resolve } from "node:path";
|
|
38
38
|
import { inference, previewInferenceRoute } from "../hooks/lib/inference";
|
|
39
39
|
import { DEBUG_LOG_MAX_ROTATED, logDebug } from "../hooks/lib/log";
|
|
40
|
-
import { palHome, palPkg, platform } from "../hooks/lib/paths";
|
|
40
|
+
import { palHome, palPkg, paths, platform } from "../hooks/lib/paths";
|
|
41
41
|
import { hasRealContent, SETUP_STEPS, STEP_ORDER } from "../hooks/lib/setup";
|
|
42
42
|
import { log } from "../targets/lib";
|
|
43
43
|
import { checkPendingMigrations } from "./migrate";
|
|
@@ -485,21 +485,27 @@ interface HookPrefixCheck {
|
|
|
485
485
|
firstMissing?: string;
|
|
486
486
|
}
|
|
487
487
|
|
|
488
|
-
/**
|
|
489
|
-
|
|
488
|
+
/**
|
|
489
|
+
* True when a hook command names its agent, by env prefix or by argv flag.
|
|
490
|
+
*
|
|
491
|
+
* An env prefix only parses in one shell family, so hook configs whose host
|
|
492
|
+
* shell is unknown declare the agent with a shell-agnostic `--agent=` flag.
|
|
493
|
+
*/
|
|
494
|
+
function declaresAgent(cmd: string, agentName: string): boolean {
|
|
490
495
|
return (
|
|
491
496
|
cmd.startsWith(`PAL_AGENT=${agentName} `) ||
|
|
492
|
-
cmd.startsWith(`$env:PAL_AGENT='${agentName}'; `)
|
|
497
|
+
cmd.startsWith(`$env:PAL_AGENT='${agentName}'; `) ||
|
|
498
|
+
cmd.includes(`--agent=${agentName}`)
|
|
493
499
|
);
|
|
494
500
|
}
|
|
495
501
|
|
|
496
|
-
/** Verify every command in an installed hook file
|
|
502
|
+
/** Verify every command in an installed hook file names `<agent>` as its agent. */
|
|
497
503
|
function checkAgentHookPrefix(filePath: string, agentName: string): HookPrefixCheck {
|
|
498
504
|
if (!existsSync(filePath)) return { ok: false, total: 0, missing: 0 };
|
|
499
505
|
try {
|
|
500
506
|
const data = JSON.parse(readFileSync(filePath, "utf-8"));
|
|
501
507
|
const commands = extractAllHookCommands(data.hooks ?? data);
|
|
502
|
-
const missing = commands.filter((c) => !
|
|
508
|
+
const missing = commands.filter((c) => !declaresAgent(c, agentName));
|
|
503
509
|
return {
|
|
504
510
|
ok: commands.length > 0 && missing.length === 0,
|
|
505
511
|
total: commands.length,
|
|
@@ -928,12 +934,12 @@ function doctor(silent = false): DoctorResult {
|
|
|
928
934
|
): void => {
|
|
929
935
|
const r = checkAgentHookPrefix(filePath, agentName);
|
|
930
936
|
if (r.ok) {
|
|
931
|
-
ok(`${agentName}:
|
|
937
|
+
ok(`${agentName}: declared on all ${r.total} hook commands`);
|
|
932
938
|
} else if (r.total === 0) {
|
|
933
939
|
fail(`${agentName}: hook file missing or unreadable at ${filePath}`);
|
|
934
940
|
} else {
|
|
935
941
|
fail(
|
|
936
|
-
`${agentName}: ${r.missing}/${r.total} hook commands
|
|
942
|
+
`${agentName}: ${r.missing}/${r.total} hook commands do not declare ${agentName} (run '${installCmd}')`
|
|
937
943
|
);
|
|
938
944
|
if (r.firstMissing) {
|
|
939
945
|
log.warn(` First offender: ${r.firstMissing}…`);
|
|
@@ -1084,6 +1090,10 @@ async function init(args: string[]) {
|
|
|
1084
1090
|
log.info(`Creating PAL home at ${home}`);
|
|
1085
1091
|
mkdirSync(resolve(home, "telos"), { recursive: true });
|
|
1086
1092
|
mkdirSync(resolve(home, "memory"), { recursive: true });
|
|
1093
|
+
// Scaffolded here, not left to generateSkillIndex: that returns early when
|
|
1094
|
+
// ~/.pal/skills is absent, so an init that installs no skills would leave
|
|
1095
|
+
// every writer of memory/state with nowhere to write.
|
|
1096
|
+
mkdirSync(resolve(home, "memory", "state"), { recursive: true });
|
|
1087
1097
|
|
|
1088
1098
|
scaffoldTelos();
|
|
1089
1099
|
|
|
@@ -1092,16 +1102,23 @@ async function init(args: string[]) {
|
|
|
1092
1102
|
await install(targets);
|
|
1093
1103
|
}
|
|
1094
1104
|
|
|
1105
|
+
/**
|
|
1106
|
+
* Run a setup subprocess, showing its output only when it fails.
|
|
1107
|
+
*
|
|
1108
|
+
* These are idempotent and usually report "no changes", so their banners are
|
|
1109
|
+
* pure noise on a re-install — but the moment one fails, the reason it gives
|
|
1110
|
+
* is the only thing that explains the warning.
|
|
1111
|
+
*/
|
|
1112
|
+
function runQuietly(cmd: string, args: string[], cwd: string): number | null {
|
|
1113
|
+
const r = spawnSync(cmd, args, { cwd, encoding: "utf-8", shell: true });
|
|
1114
|
+
if (r.status !== 0) process.stderr.write((r.stdout ?? "") + (r.stderr ?? ""));
|
|
1115
|
+
return r.status;
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1095
1118
|
async function install(targets: Targets) {
|
|
1096
1119
|
// Ensure dependencies are installed
|
|
1097
1120
|
const pkg = palPkg();
|
|
1098
|
-
|
|
1099
|
-
const deps = spawnSync("bun", ["install", "--frozen-lockfile"], {
|
|
1100
|
-
cwd: pkg,
|
|
1101
|
-
stdio: "inherit",
|
|
1102
|
-
shell: true,
|
|
1103
|
-
});
|
|
1104
|
-
if (deps.status !== 0) {
|
|
1121
|
+
if (runQuietly("bun", ["install", "--frozen-lockfile"], pkg) !== 0) {
|
|
1105
1122
|
log.warn("bun install failed — continuing anyway, but hooks may not work");
|
|
1106
1123
|
}
|
|
1107
1124
|
|
|
@@ -1110,15 +1127,10 @@ async function install(targets: Targets) {
|
|
|
1110
1127
|
// (used by tests to avoid a ~150MB download on every run).
|
|
1111
1128
|
// Uses `bun x` (not `bunx`) for Windows compatibility — bunx resolves unreliably under cmd.exe.
|
|
1112
1129
|
if (process.env.PAL_SKIP_BROWSER_INSTALL !== "1") {
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
cwd: pkg,
|
|
1116
|
-
stdio: "inherit",
|
|
1117
|
-
shell: true,
|
|
1118
|
-
});
|
|
1119
|
-
if (pw.status !== 0) {
|
|
1130
|
+
const pw = runQuietly("bun", ["x", "playwright", "install", "chromium"], pkg);
|
|
1131
|
+
if (pw !== 0) {
|
|
1120
1132
|
log.warn(
|
|
1121
|
-
`playwright install chromium failed (exit ${pw
|
|
1133
|
+
`playwright install chromium failed (exit ${pw}) — create-pdf and consulting-report skills won't work. Retry manually: bun x playwright install chromium`
|
|
1122
1134
|
);
|
|
1123
1135
|
}
|
|
1124
1136
|
}
|
|
@@ -1138,7 +1150,8 @@ async function install(targets: Targets) {
|
|
|
1138
1150
|
}
|
|
1139
1151
|
|
|
1140
1152
|
// Scaffold TELOS + PAL settings, then prompt for missing identity
|
|
1141
|
-
const { scaffoldTelos, scaffoldPalSettings } =
|
|
1153
|
+
const { scaffoldTelos, scaffoldPalSettings, copyPalDocs, generateSkillIndex } =
|
|
1154
|
+
await import("../targets/lib");
|
|
1142
1155
|
const { promptIdentity } = await import("./setup-identity");
|
|
1143
1156
|
const { promptTelos } = await import("./setup-telos");
|
|
1144
1157
|
const { promptAttribution } = await import("./setup-attribution");
|
|
@@ -1148,6 +1161,13 @@ async function install(targets: Targets) {
|
|
|
1148
1161
|
await promptTelos();
|
|
1149
1162
|
await promptAttribution();
|
|
1150
1163
|
|
|
1164
|
+
// Shared, target-independent state. Every target installer used to repeat these
|
|
1165
|
+
// identical calls; AGENTS.md in particular must exist before any target symlinks
|
|
1166
|
+
// to it, so it runs once here rather than once per target.
|
|
1167
|
+
const { regenerateIfNeeded } = await import("../hooks/lib/claude-md");
|
|
1168
|
+
const palDocsCount = copyPalDocs();
|
|
1169
|
+
regenerateIfNeeded();
|
|
1170
|
+
|
|
1151
1171
|
if (targets.claude) {
|
|
1152
1172
|
console.log("━━━ Claude Code ━━━");
|
|
1153
1173
|
await import("../targets/claude/install");
|
|
@@ -1178,6 +1198,16 @@ async function install(targets: Targets) {
|
|
|
1178
1198
|
console.log("");
|
|
1179
1199
|
}
|
|
1180
1200
|
|
|
1201
|
+
// The rest of the shared work reads what the installers just wrote: the index
|
|
1202
|
+
// walks ~/.pal/skills, and the digests land in ~/.cursor/rules and
|
|
1203
|
+
// ~/.copilot/instructions, which are skipped when the agent's home is absent.
|
|
1204
|
+
const { writeContextDigests } = await import("../hooks/handlers/context-digests");
|
|
1205
|
+
const indexedSkills = generateSkillIndex();
|
|
1206
|
+
writeContextDigests();
|
|
1207
|
+
log.success(
|
|
1208
|
+
`Shared: ${indexedSkills} skills indexed · ${palDocsCount} docs → ~/.pal/docs/ · AGENTS.md + context digests written`
|
|
1209
|
+
);
|
|
1210
|
+
|
|
1181
1211
|
log.success("Done. Existing config was preserved — only new entries were added.");
|
|
1182
1212
|
}
|
|
1183
1213
|
|
|
@@ -1403,7 +1433,9 @@ async function update() {
|
|
|
1403
1433
|
function cliDebug(args: string[]) {
|
|
1404
1434
|
const stateDir = resolve(palHome(), "memory", "state");
|
|
1405
1435
|
const flagFile = resolve(stateDir, "debug-enabled");
|
|
1406
|
-
|
|
1436
|
+
// Must match log.ts's logFile() — reporting a different path sends anyone
|
|
1437
|
+
// debugging a hook to an empty file.
|
|
1438
|
+
const logFile = resolve(paths.debug(), "debug.log");
|
|
1407
1439
|
const sub = args[0];
|
|
1408
1440
|
if (sub === "on") {
|
|
1409
1441
|
mkdirSync(stateDir, { recursive: true });
|
|
@@ -1,34 +1,81 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Hook: PreToolUse — Guards against dangerous commands.
|
|
3
|
-
*
|
|
3
|
+
* Emits the current agent's deny response to block, or exits silently to allow.
|
|
4
4
|
*
|
|
5
5
|
* Fail-open design: if anything goes wrong, the command is allowed through.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { blockResponse } from "./lib/agent";
|
|
8
|
+
import { blockResponse, normalizeToolUse } from "./lib/agent";
|
|
9
|
+
import { logDebug } from "./lib/log";
|
|
9
10
|
import { checkBashCommand, checkFilePath } from "./lib/security";
|
|
10
11
|
import { readStdinJSON } from "./lib/stdin";
|
|
11
12
|
|
|
12
|
-
//
|
|
13
|
-
interface ToolUseInput {
|
|
14
|
-
tool_name: string;
|
|
15
|
-
hook_event_name?: string; // Codex includes this in all hook inputs
|
|
16
|
-
tool_input: {
|
|
17
|
-
command?: string;
|
|
18
|
-
file_path?: string;
|
|
19
|
-
};
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
// beforeShellExecution shape (Cursor only) — flat, no tool_name wrapper
|
|
13
|
+
// beforeShellExecution shape (Cursor only) — flat, no tool-name wrapper
|
|
23
14
|
interface ShellExecInput {
|
|
24
15
|
command: string;
|
|
25
16
|
sandbox?: boolean;
|
|
26
17
|
}
|
|
27
18
|
|
|
28
|
-
type SecurityInput =
|
|
19
|
+
type SecurityInput = Record<string, unknown> | ShellExecInput;
|
|
29
20
|
|
|
30
21
|
function isShellExec(input: SecurityInput): input is ShellExecInput {
|
|
31
|
-
return !("tool_name" in input) && "command" in input;
|
|
22
|
+
return !("tool_name" in input) && !("toolName" in input) && "command" in input;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// A name this list misses is a command this hook waves through, so both sets mirror
|
|
26
|
+
// the tool names VS Code's own Copilot build ships in its shell and edit tool sets.
|
|
27
|
+
const SHELL_TOOLS = [
|
|
28
|
+
"bash",
|
|
29
|
+
"shell",
|
|
30
|
+
"powershell",
|
|
31
|
+
"local_shell",
|
|
32
|
+
"runinterminal",
|
|
33
|
+
"run_in_terminal",
|
|
34
|
+
"terminal",
|
|
35
|
+
"execute_command",
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
const FILE_WRITE_TOOLS = [
|
|
39
|
+
"write",
|
|
40
|
+
"edit",
|
|
41
|
+
"multiedit",
|
|
42
|
+
"write_file",
|
|
43
|
+
"apply_patch",
|
|
44
|
+
"applypatch",
|
|
45
|
+
"create",
|
|
46
|
+
"create_file",
|
|
47
|
+
"createfile",
|
|
48
|
+
"str_replace",
|
|
49
|
+
"str_replace_editor",
|
|
50
|
+
"insert",
|
|
51
|
+
"insert_edit_into_file",
|
|
52
|
+
"replace_string_in_file",
|
|
53
|
+
"multi_replace_string_in_file",
|
|
54
|
+
"replacestring",
|
|
55
|
+
"edit_notebook_file",
|
|
56
|
+
"notebookedit",
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
/** First of `keys` present as a non-empty string — agents disagree on argument spelling. */
|
|
60
|
+
function firstStringArg(
|
|
61
|
+
args: Record<string, unknown>,
|
|
62
|
+
keys: string[]
|
|
63
|
+
): string | undefined {
|
|
64
|
+
for (const key of keys) {
|
|
65
|
+
const value = args[key];
|
|
66
|
+
if (typeof value === "string" && value.length > 0) return value;
|
|
67
|
+
}
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Tool names that run a shell command, across every agent's naming. */
|
|
72
|
+
function runsShellCommand(toolName: string): boolean {
|
|
73
|
+
return SHELL_TOOLS.includes(toolName.toLowerCase());
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Tool names that write to a file, across every agent's naming. */
|
|
77
|
+
function writesFile(toolName: string): boolean {
|
|
78
|
+
return FILE_WRITE_TOOLS.includes(toolName.toLowerCase());
|
|
32
79
|
}
|
|
33
80
|
|
|
34
81
|
try {
|
|
@@ -44,31 +91,35 @@ try {
|
|
|
44
91
|
process.exit(0);
|
|
45
92
|
}
|
|
46
93
|
|
|
47
|
-
const
|
|
94
|
+
const toolUse = normalizeToolUse(input);
|
|
95
|
+
if (!toolUse) process.exit(0);
|
|
48
96
|
|
|
49
|
-
//
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
input.tool_name === "Write" ||
|
|
56
|
-
input.tool_name === "Edit" ||
|
|
57
|
-
input.tool_name === "write_file" ||
|
|
58
|
-
input.tool_name === "apply_patch";
|
|
97
|
+
// Each agent names its shell/write tools differently; log the real name so an
|
|
98
|
+
// unrecognized one shows up here instead of silently skipping the check.
|
|
99
|
+
logDebug(
|
|
100
|
+
"SecurityValidator",
|
|
101
|
+
`toolName=${toolUse.toolName} args=${Object.keys(toolUse.toolInput).join(",")}`
|
|
102
|
+
);
|
|
59
103
|
|
|
60
|
-
|
|
61
|
-
|
|
104
|
+
const command = firstStringArg(toolUse.toolInput, ["command", "commandLine", "script"]);
|
|
105
|
+
if (runsShellCommand(toolUse.toolName) && typeof command === "string") {
|
|
106
|
+
const reason = checkBashCommand(command);
|
|
107
|
+
const verdict = reason ? `BLOCK(${reason})` : "ALLOW";
|
|
108
|
+
// "No output" from a downstream tool is indistinguishable between "denied,
|
|
109
|
+
// never ran" and "ran, produced nothing" — logging the verdict here, next
|
|
110
|
+
// to the literal command, is what actually tells the two apart.
|
|
111
|
+
logDebug("SecurityValidator", `bashVerdict=${verdict} command=${command}`);
|
|
62
112
|
if (reason) {
|
|
63
|
-
process.stdout.write(blockResponse(`Blocked: ${reason}`, hookEventName));
|
|
113
|
+
process.stdout.write(blockResponse(`Blocked: ${reason}`, toolUse.hookEventName));
|
|
64
114
|
process.exit(0);
|
|
65
115
|
}
|
|
66
116
|
}
|
|
67
117
|
|
|
68
|
-
|
|
69
|
-
|
|
118
|
+
const filePath = firstStringArg(toolUse.toolInput, ["file_path", "filePath", "path"]);
|
|
119
|
+
if (writesFile(toolUse.toolName) && typeof filePath === "string") {
|
|
120
|
+
const reason = checkFilePath(filePath);
|
|
70
121
|
if (reason) {
|
|
71
|
-
process.stdout.write(blockResponse(reason, hookEventName));
|
|
122
|
+
process.stdout.write(blockResponse(reason, toolUse.hookEventName));
|
|
72
123
|
process.exit(0);
|
|
73
124
|
}
|
|
74
125
|
}
|
package/src/hooks/SkillGuard.ts
CHANGED
|
@@ -9,29 +9,25 @@
|
|
|
9
9
|
* Fail-open: on any error, the skill is allowed through.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { blockResponse } from "./lib/agent";
|
|
12
|
+
import { blockResponse, normalizeToolUse } from "./lib/agent";
|
|
13
13
|
import { readStdinJSON } from "./lib/stdin";
|
|
14
14
|
|
|
15
15
|
const BLOCKED_SKILLS = ["keybindings-help"];
|
|
16
16
|
|
|
17
|
-
interface SkillInput {
|
|
18
|
-
tool_name: string;
|
|
19
|
-
tool_input: {
|
|
20
|
-
skill?: string;
|
|
21
|
-
};
|
|
22
|
-
}
|
|
23
|
-
|
|
24
17
|
try {
|
|
25
|
-
const
|
|
26
|
-
if (!
|
|
18
|
+
const toolUse = normalizeToolUse(await readStdinJSON());
|
|
19
|
+
if (!toolUse) process.exit(0);
|
|
27
20
|
|
|
28
|
-
const skill = (
|
|
21
|
+
const skill = String(toolUse.toolInput.skill ?? "")
|
|
22
|
+
.toLowerCase()
|
|
23
|
+
.trim();
|
|
29
24
|
|
|
30
25
|
if (BLOCKED_SKILLS.includes(skill)) {
|
|
31
26
|
process.stdout.write(
|
|
32
27
|
blockResponse(
|
|
33
28
|
'BLOCKED: "keybindings-help" is a known false-positive triggered by position bias. ' +
|
|
34
|
-
"The user did NOT ask about keybindings. Continue with their ACTUAL request."
|
|
29
|
+
"The user did NOT ask about keybindings. Continue with their ACTUAL request.",
|
|
30
|
+
toolUse.hookEventName
|
|
35
31
|
)
|
|
36
32
|
);
|
|
37
33
|
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { checkReadmeSync } from "./handlers/readme-sync";
|
|
10
|
-
import { isCodex, isCursor } from "./lib/agent";
|
|
10
|
+
import { blockResponse, isCodex, isCursor } from "./lib/agent";
|
|
11
11
|
import { logError } from "./lib/log";
|
|
12
12
|
import { isPalSpawnedInference } from "./lib/spawn-guard";
|
|
13
13
|
import { readStdinJSON } from "./lib/stdin";
|
|
@@ -29,8 +29,10 @@ interface StopHookInput {
|
|
|
29
29
|
|
|
30
30
|
// Check README sync before anything else — may block the session
|
|
31
31
|
try {
|
|
32
|
+
// A block carrying no reason stops the turn without telling the model why, so it
|
|
33
|
+
// is worth less than not blocking at all — require the reason to raise one.
|
|
32
34
|
const decision = checkReadmeSync();
|
|
33
|
-
if (decision.decision === "block") {
|
|
35
|
+
if (decision.decision === "block" && decision.reason) {
|
|
34
36
|
if (isCursor()) {
|
|
35
37
|
// Cursor stop hook: followup_message auto-sends to the agent
|
|
36
38
|
process.stdout.write(JSON.stringify({ followup_message: decision.reason }));
|
|
@@ -38,8 +40,9 @@ try {
|
|
|
38
40
|
// Codex stop hook: additionalContext re-queues as next prompt
|
|
39
41
|
process.stdout.write(JSON.stringify({ additionalContext: decision.reason }));
|
|
40
42
|
} else {
|
|
41
|
-
// Claude Code
|
|
42
|
-
|
|
43
|
+
// Claude Code, the Copilot CLI and VS Code's own Copilot each read a
|
|
44
|
+
// different stop-block shape; VS Code ignores the top-level keys entirely.
|
|
45
|
+
process.stdout.write(blockResponse(decision.reason, "Stop"));
|
|
43
46
|
}
|
|
44
47
|
process.exit(0);
|
|
45
48
|
}
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Called from UserPromptOrchestrator. Reads the retrieval index, ranks the prompt
|
|
5
5
|
* against the corpus, prints a `<system-reminder>` block to stdout (Claude Code
|
|
6
|
-
* prepends UserPromptSubmit hook stdout to the prompt). Fail-closed: any error
|
|
7
|
-
*
|
|
6
|
+
* prepends UserPromptSubmit hook stdout to the prompt). Fail-closed: any error
|
|
7
|
+
* produces empty output, never blocks the prompt.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { isCodex, isCursor } from "../lib/agent";
|
|
@@ -14,21 +14,25 @@ import { ensureIndex } from "../lib/retrieval-index";
|
|
|
14
14
|
import { isEnabled } from "../lib/settings";
|
|
15
15
|
import { getSteeringReminder } from "../lib/steering";
|
|
16
16
|
|
|
17
|
-
const
|
|
17
|
+
const BUDGET_MS = 250;
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
19
|
+
/** Run sync work on the prompt path, containing any throw. A synchronous call cannot
|
|
20
|
+
* be preempted on a single thread, so the budget is measured and logged, never
|
|
21
|
+
* enforced — an overrun still returns its result rather than being discarded.
|
|
22
|
+
* @lintignore exported for test/inject-retrieval.test.ts */
|
|
23
|
+
export function withinBudget<T>(work: () => T, ms: number): T | null {
|
|
24
|
+
const started = performance.now();
|
|
25
|
+
try {
|
|
26
|
+
return work();
|
|
27
|
+
} catch (err) {
|
|
28
|
+
logError("inject-retrieval", err);
|
|
29
|
+
return null;
|
|
30
|
+
} finally {
|
|
31
|
+
const elapsed = performance.now() - started;
|
|
32
|
+
if (elapsed > ms) {
|
|
33
|
+
logDebug("inject-retrieval", `over budget: ${elapsed.toFixed(0)}ms > ${ms}ms`);
|
|
30
34
|
}
|
|
31
|
-
}
|
|
35
|
+
}
|
|
32
36
|
}
|
|
33
37
|
|
|
34
38
|
/** Returns the retrieval reminder string, or null if nothing to inject. @lintignore dynamically imported by opencode plugin */
|
|
@@ -36,11 +40,11 @@ export async function getRetrievalReminder(prompt: string): Promise<string | nul
|
|
|
36
40
|
if (!prompt?.trim()) return null;
|
|
37
41
|
if (!isEnabled("learningInjection")) return null;
|
|
38
42
|
|
|
39
|
-
const result =
|
|
43
|
+
const result = withinBudget(() => {
|
|
40
44
|
const index = ensureIndex();
|
|
41
45
|
if (index.corpusSize === 0) return null;
|
|
42
46
|
return runRetrieval(prompt, index, process.cwd());
|
|
43
|
-
},
|
|
47
|
+
}, BUDGET_MS);
|
|
44
48
|
|
|
45
49
|
if (!result?.reminder) return null;
|
|
46
50
|
|
package/src/hooks/lib/agent.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* vars are used as secondary fallbacks for environments that forward them.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
export type AgentType = "claude" | "cursor" | "codex" | "copilot" | "opencode";
|
|
14
|
+
export type AgentType = "claude" | "cursor" | "codex" | "copilot" | "opencode" | "vscode";
|
|
15
15
|
|
|
16
16
|
const KNOWN_AGENTS: ReadonlySet<AgentType> = new Set([
|
|
17
17
|
"claude",
|
|
@@ -19,14 +19,33 @@ const KNOWN_AGENTS: ReadonlySet<AgentType> = new Set([
|
|
|
19
19
|
"codex",
|
|
20
20
|
"copilot",
|
|
21
21
|
"opencode",
|
|
22
|
+
"vscode",
|
|
22
23
|
]);
|
|
23
24
|
|
|
25
|
+
function agentFromEnv(): AgentType | undefined {
|
|
26
|
+
const explicit = process.env.PAL_AGENT;
|
|
27
|
+
return explicit && KNOWN_AGENTS.has(explicit as AgentType)
|
|
28
|
+
? (explicit as AgentType)
|
|
29
|
+
: undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* `--agent=<name>` on the hook's own command line.
|
|
34
|
+
*
|
|
35
|
+
* An `PAL_AGENT=x cmd` prefix is POSIX-only and an `$env:PAL_AGENT='x'; cmd`
|
|
36
|
+
* prefix is PowerShell-only, so a hook config that guesses the host's shell
|
|
37
|
+
* wrong fails before the hook ever runs. An argv flag is shell-agnostic.
|
|
38
|
+
*/
|
|
39
|
+
function agentFromArgv(): AgentType | undefined {
|
|
40
|
+
const flag = process.argv.find((a) => a.startsWith("--agent="));
|
|
41
|
+
const value = flag?.slice("--agent=".length);
|
|
42
|
+
return value && KNOWN_AGENTS.has(value as AgentType) ? (value as AgentType) : undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
24
45
|
/** Detect which agent is currently running PAL. Defaults to "claude". */
|
|
25
46
|
export function getActiveAgent(): AgentType {
|
|
26
|
-
const
|
|
27
|
-
if (
|
|
28
|
-
return explicit as AgentType;
|
|
29
|
-
}
|
|
47
|
+
const declared = agentFromArgv() ?? agentFromEnv();
|
|
48
|
+
if (declared) return declared;
|
|
30
49
|
if (process.env.CURSOR_VERSION) return "cursor";
|
|
31
50
|
if (process.env.CODEX_CLI_VERSION ?? process.env.OPENAI_CODEX) return "codex";
|
|
32
51
|
return "claude";
|
|
@@ -37,17 +56,96 @@ export const isCursor = () => getActiveAgent() === "cursor";
|
|
|
37
56
|
export const isCodex = () => getActiveAgent() === "codex";
|
|
38
57
|
export const isCopilot = () => getActiveAgent() === "copilot";
|
|
39
58
|
export const isOpencode = () => getActiveAgent() === "opencode";
|
|
59
|
+
const isVscode = () => getActiveAgent() === "vscode";
|
|
60
|
+
|
|
61
|
+
/** Normalized preToolUse request — one shape for every agent's payload. */
|
|
62
|
+
export interface ToolUseRequest {
|
|
63
|
+
toolName: string;
|
|
64
|
+
toolInput: Record<string, unknown>;
|
|
65
|
+
hookEventName?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function firstString(...values: unknown[]): string | undefined {
|
|
69
|
+
return values.find((v): v is string => typeof v === "string" && v.length > 0);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function firstObject(...values: unknown[]): Record<string, unknown> | undefined {
|
|
73
|
+
return values.find(
|
|
74
|
+
(v): v is Record<string, unknown> => typeof v === "object" && v !== null
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Normalize a preToolUse payload across agents.
|
|
80
|
+
*
|
|
81
|
+
* Claude Code, Cursor, Codex — and Copilot's VS Code-compatible mode — send
|
|
82
|
+
* snake_case `tool_name` + `tool_input`. Copilot's native CLI payload sends
|
|
83
|
+
* camelCase `toolName` + `toolArgs`. A hook reading only one shape matches
|
|
84
|
+
* nothing on the other, which for a security hook silently means "allow".
|
|
85
|
+
*/
|
|
86
|
+
export function normalizeToolUse(raw: unknown): ToolUseRequest | null {
|
|
87
|
+
const payload = firstObject(raw);
|
|
88
|
+
if (!payload) return null;
|
|
89
|
+
const toolName = firstString(payload.tool_name, payload.toolName);
|
|
90
|
+
if (!toolName) return null;
|
|
91
|
+
return {
|
|
92
|
+
toolName,
|
|
93
|
+
toolInput: firstObject(payload.tool_input, payload.toolArgs, payload.toolInput) ?? {},
|
|
94
|
+
hookEventName: firstString(payload.hook_event_name, payload.hookEventName),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
40
97
|
|
|
41
98
|
/**
|
|
42
99
|
* Format a "block this action" response for the current agent.
|
|
43
|
-
* Claude Code
|
|
44
|
-
* Cursor preToolUse:
|
|
45
|
-
*
|
|
100
|
+
* Claude Code / VS Code: both spellings at once — see claudeBlock below
|
|
101
|
+
* Cursor preToolUse: { permission: "deny", user_message }
|
|
102
|
+
* Copilot preToolUse: { permissionDecision: "deny", permissionDecisionReason }
|
|
103
|
+
* Copilot agentStop: { decision: "block", reason }
|
|
104
|
+
* Codex PreToolUse: { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: reason } }
|
|
105
|
+
*
|
|
106
|
+
* A stop event denies the whole turn, not one tool call, so it carries a
|
|
107
|
+
* decision rather than a permission — callers must name the event to get it.
|
|
46
108
|
*/
|
|
109
|
+
function isStopEvent(hookEventName?: string): boolean {
|
|
110
|
+
return hookEventName === "Stop" || hookEventName === "agentStop";
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* One payload both Claude Code and VS Code's own Copilot build accept.
|
|
115
|
+
*
|
|
116
|
+
* VS Code reads every decision from inside hookSpecificOutput and ignores the
|
|
117
|
+
* top-level keys; Claude Code reads the top-level keys and ignores the extra
|
|
118
|
+
* object (verified against `claude -p`: a turn carrying both is still blocked).
|
|
119
|
+
* Since VS Code also executes the hooks registered in ~/.claude/settings.json,
|
|
120
|
+
* carrying both spellings here is what lets one registration serve both — a
|
|
121
|
+
* second VS Code-specific hooks file made every event run twice.
|
|
122
|
+
*/
|
|
123
|
+
function claudeBlock(reason: string, hookEventName?: string): string {
|
|
124
|
+
return JSON.stringify({
|
|
125
|
+
decision: "block",
|
|
126
|
+
reason,
|
|
127
|
+
hookSpecificOutput: isStopEvent(hookEventName)
|
|
128
|
+
? { hookEventName: "Stop", decision: "block", reason }
|
|
129
|
+
: {
|
|
130
|
+
hookEventName: "PreToolUse",
|
|
131
|
+
permissionDecision: "deny",
|
|
132
|
+
permissionDecisionReason: reason,
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
47
137
|
export function blockResponse(reason: string, hookEventName?: string): string {
|
|
48
138
|
if (isCursor()) {
|
|
49
139
|
return JSON.stringify({ permission: "deny", user_message: reason });
|
|
50
140
|
}
|
|
141
|
+
if (isCopilot()) {
|
|
142
|
+
return isStopEvent(hookEventName)
|
|
143
|
+
? JSON.stringify({ decision: "block", reason })
|
|
144
|
+
: JSON.stringify({
|
|
145
|
+
permissionDecision: "deny",
|
|
146
|
+
permissionDecisionReason: reason,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
51
149
|
if (isCodex() && hookEventName === "PreToolUse") {
|
|
52
150
|
return JSON.stringify({
|
|
53
151
|
hookSpecificOutput: {
|
|
@@ -57,5 +155,11 @@ export function blockResponse(reason: string, hookEventName?: string): string {
|
|
|
57
155
|
},
|
|
58
156
|
});
|
|
59
157
|
}
|
|
158
|
+
// Only the surfaces that share ~/.claude/settings.json need both spellings.
|
|
159
|
+
// Handing the extra key to codex or opencode would be a shape they never
|
|
160
|
+
// asked to parse, for a duplication problem they don't have.
|
|
161
|
+
if (isClaude() || isVscode()) {
|
|
162
|
+
return claudeBlock(reason, hookEventName);
|
|
163
|
+
}
|
|
60
164
|
return JSON.stringify({ decision: "block", reason });
|
|
61
165
|
}
|
|
@@ -5,6 +5,77 @@
|
|
|
5
5
|
|
|
6
6
|
import { lstatSync } from "node:fs";
|
|
7
7
|
|
|
8
|
+
// PowerShell aliases rm, rmdir, del, erase, rd and ri all to Remove-Item, and
|
|
9
|
+
// cmd ships its own rd and del — so the verb alone never says which shell ran it.
|
|
10
|
+
const WIN_DELETE_VERB = "(?:remove-item|rmdir|erase|del|rd|rm|ri)";
|
|
11
|
+
|
|
12
|
+
// -r through -Recurse all bind in PowerShell; cmd's rd/del spell it /s.
|
|
13
|
+
const WIN_RECURSE_FLAG = String.raw`(?:-(?:r(?:e(?:c(?:u(?:r(?:se?)?)?)?)?)?f?|fr)|/s)\b`;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A whole root, not a directory inside one. The trailing lookahead is the part
|
|
17
|
+
* that matters: without it `C:\` prefix-matches `C:\Users\rico\dist` and every
|
|
18
|
+
* ordinary recursive delete on Windows gets blocked.
|
|
19
|
+
*/
|
|
20
|
+
const WIN_ROOT_TARGET = String.raw`["']?(?:[a-z]:[\\/]?\*?|\\\\|~|\$home|\$env:userprofile|\$env:systemdrive)["']?(?=["'\s;,)]|$)`;
|
|
21
|
+
|
|
22
|
+
const WIN_DOWNLOAD = "(?:iwr|irm|curl|wget|invoke-webrequest|invoke-restmethod)";
|
|
23
|
+
const WIN_EVAL = "(?:iex|invoke-expression)";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Something is about to be run, rather than merely named. `format` and
|
|
27
|
+
* `diskpart` are bare enough to collide with ordinary text — a PR title reading
|
|
28
|
+
* `fix: format C: handling` or `rg 'diskpart' docs/` are not disk operations.
|
|
29
|
+
* The optional wrapper keeps `powershell -c "format C:"` in scope.
|
|
30
|
+
*/
|
|
31
|
+
const SHELL_WRAPPER = String.raw`(?:(?:sudo|powershell(?:\.exe)?|pwsh|cmd(?:\.exe)?)\s+(?:[-/]\w+\s+)*)?`;
|
|
32
|
+
const COMMAND_POSITION = String.raw`(?:^|[|;&\n({])\s*${SHELL_WRAPPER}["']?`;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Start-Process/runas/gsudo hand the target to a flag (-FilePath, -ArgumentList)
|
|
36
|
+
* or a positional slot after other flags, in either order — COMMAND_POSITION's
|
|
37
|
+
* fixed wrapper-then-verb shape can't follow that. Since nobody launches a
|
|
38
|
+
* process via Start-Process to hold a PR title, an elevation wrapper anywhere in
|
|
39
|
+
* the command is itself enough license to drop the position anchor entirely.
|
|
40
|
+
*/
|
|
41
|
+
/**
|
|
42
|
+
* Both lookaheads stop at |, ; and & so they cannot reach across a command
|
|
43
|
+
* boundary — otherwise `rm -r build; echo C:\` reads as a root delete.
|
|
44
|
+
*/
|
|
45
|
+
const WIN_ROOT_DELETE = new RegExp(
|
|
46
|
+
String.raw`${COMMAND_POSITION}${WIN_DELETE_VERB}\b(?=[^|;&\n]*\s${WIN_RECURSE_FLAG})(?=[^|;&\n]*\s${WIN_ROOT_TARGET})`,
|
|
47
|
+
"i"
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
const WIN_FORMAT_COMMAND = new RegExp(
|
|
51
|
+
String.raw`${COMMAND_POSITION}format(?:\s+["']?[a-z]:|-volume\b)`,
|
|
52
|
+
"i"
|
|
53
|
+
);
|
|
54
|
+
const WIN_DISKPART_COMMAND = new RegExp(String.raw`${COMMAND_POSITION}diskpart\b`, "i");
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Start-Process/runas/gsudo hand the target to a flag (-FilePath, -ArgumentList)
|
|
58
|
+
* or a positional slot after other flags, in either order — COMMAND_POSITION's
|
|
59
|
+
* fixed wrapper-then-verb shape can't follow that, and a single combined regex
|
|
60
|
+
* can't either: a lookahead only sees forward from the verb, so it misses
|
|
61
|
+
* `Start-Process -Verb RunAs -FilePath diskpart` where the wrapper comes first.
|
|
62
|
+
* Two independent whole-string checks (wrapper present, threat present anywhere)
|
|
63
|
+
* sidestep the ordering problem entirely. Nobody launches a process via
|
|
64
|
+
* Start-Process to hold a PR title, so no position anchor is needed here.
|
|
65
|
+
*/
|
|
66
|
+
const WIN_ELEVATION_WRAPPER = /\b(?:start-process|runas|gsudo)\b/i;
|
|
67
|
+
const WIN_ELEVATED_THREATS: [RegExp, string][] = [
|
|
68
|
+
[
|
|
69
|
+
new RegExp(
|
|
70
|
+
String.raw`\b${WIN_DELETE_VERB}\b(?=[^|;&\n]*\s${WIN_RECURSE_FLAG})(?=[^|;&\n]*\s${WIN_ROOT_TARGET})`,
|
|
71
|
+
"i"
|
|
72
|
+
),
|
|
73
|
+
"Recursive delete of a drive root or home",
|
|
74
|
+
],
|
|
75
|
+
[/\bformat(?:\s+["']?[a-z]:|-volume\b)/i, "Disk format"],
|
|
76
|
+
[/\bdiskpart\b/i, "Disk partitioning"],
|
|
77
|
+
];
|
|
78
|
+
|
|
8
79
|
/** Dangerous command patterns — always blocked */
|
|
9
80
|
const BLOCKED_COMMANDS: [RegExp, string][] = [
|
|
10
81
|
[/rm\s+-rf\s+[/~]/, "Recursive delete of root or home"],
|
|
@@ -15,6 +86,20 @@ const BLOCKED_COMMANDS: [RegExp, string][] = [
|
|
|
15
86
|
[/:\(\)\{\s*:\|:&\s*\};:/, "Fork bomb"],
|
|
16
87
|
[/curl.*\|\s*(?:ba)?sh/, "Pipe to shell"],
|
|
17
88
|
[/wget.*\|\s*(?:ba)?sh/, "Pipe to shell"],
|
|
89
|
+
[WIN_ROOT_DELETE, "Recursive delete of a drive root or home"],
|
|
90
|
+
[WIN_FORMAT_COMMAND, "Disk format"],
|
|
91
|
+
[WIN_DISKPART_COMMAND, "Disk partitioning"],
|
|
92
|
+
[
|
|
93
|
+
new RegExp(String.raw`\b${WIN_DOWNLOAD}\b[^|\n]*\|\s*${WIN_EVAL}\b`, "i"),
|
|
94
|
+
"Pipe to shell",
|
|
95
|
+
],
|
|
96
|
+
[
|
|
97
|
+
new RegExp(
|
|
98
|
+
String.raw`\b${WIN_EVAL}\b[^|\n]*(?:downloadstring|downloadfile|new-object\s+(?:system\.)?net\.webclient|\b${WIN_DOWNLOAD}\b)`,
|
|
99
|
+
"i"
|
|
100
|
+
),
|
|
101
|
+
"Download and execute",
|
|
102
|
+
],
|
|
18
103
|
];
|
|
19
104
|
|
|
20
105
|
/** Hook-managed files — single source of truth */
|
|
@@ -99,6 +184,11 @@ export function checkBashCommand(cmd: string): string | null {
|
|
|
99
184
|
for (const [pattern, reason] of BLOCKED_COMMANDS) {
|
|
100
185
|
if (pattern.test(cmd)) return reason;
|
|
101
186
|
}
|
|
187
|
+
if (WIN_ELEVATION_WRAPPER.test(cmd)) {
|
|
188
|
+
for (const [pattern, reason] of WIN_ELEVATED_THREATS) {
|
|
189
|
+
if (pattern.test(cmd)) return reason;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
102
192
|
// If command references a managed file in a managed root path, block unless read-only.
|
|
103
193
|
// The filename must appear IN the same path as the managed root (e.g. .pal/.../file.json).
|
|
104
194
|
const segments = cmd.split(/[|;&&]/).map((s) => s.trim());
|
|
@@ -20,9 +20,42 @@ export function parseMessages(raw: string): Message[] {
|
|
|
20
20
|
}
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
function claudeCodeEntryText(msg: { content?: unknown }): string {
|
|
24
|
+
if (typeof msg.content === "string") return msg.content;
|
|
25
|
+
if (Array.isArray(msg.content)) {
|
|
26
|
+
return msg.content
|
|
27
|
+
.filter((c: { type: string }) => c.type === "text")
|
|
28
|
+
.map((c: { text: string }) => c.text)
|
|
29
|
+
.join(" ");
|
|
30
|
+
}
|
|
31
|
+
return "";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Claude Code tags transcript lines `type: "user"|"assistant"` with the text
|
|
35
|
+
// under `message.content`. VS Code Copilot's own event log instead uses
|
|
36
|
+
// `type: "user.message"|"assistant.message"` with a flat `data.content`
|
|
37
|
+
// string — two shapes sharing one transcript_path contract across agents.
|
|
38
|
+
function parseTranscriptEntry(entry: {
|
|
39
|
+
type?: string;
|
|
40
|
+
message?: { content?: unknown };
|
|
41
|
+
data?: { content?: unknown };
|
|
42
|
+
}): Message | null {
|
|
43
|
+
if (entry.type === "user" || entry.type === "assistant") {
|
|
44
|
+
const text = claudeCodeEntryText(entry.message ?? {});
|
|
45
|
+
return text ? { role: entry.type, content: text } : null;
|
|
46
|
+
}
|
|
47
|
+
if (entry.type === "user.message" || entry.type === "assistant.message") {
|
|
48
|
+
const text = entry.data?.content;
|
|
49
|
+
const role = entry.type === "user.message" ? "user" : "assistant";
|
|
50
|
+
return typeof text === "string" && text ? { role, content: text } : null;
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
23
55
|
/**
|
|
24
|
-
* Read
|
|
25
|
-
*
|
|
56
|
+
* Read an agent transcript JSONL file and extract user/assistant messages.
|
|
57
|
+
* Supports Claude Code's `{type:"user"|"assistant", message:{content}}` shape
|
|
58
|
+
* and VS Code Copilot's `{type:"user.message"|"assistant.message", data:{content}}` shape.
|
|
26
59
|
*/
|
|
27
60
|
export function readTranscriptFile(path: string): Message[] {
|
|
28
61
|
try {
|
|
@@ -32,22 +65,8 @@ export function readTranscriptFile(path: string): Message[] {
|
|
|
32
65
|
for (const line of content.split("\n")) {
|
|
33
66
|
if (!line.trim()) continue;
|
|
34
67
|
try {
|
|
35
|
-
const
|
|
36
|
-
if (
|
|
37
|
-
const msg = entry.message ?? {};
|
|
38
|
-
let text = "";
|
|
39
|
-
if (typeof msg.content === "string") {
|
|
40
|
-
text = msg.content;
|
|
41
|
-
} else if (Array.isArray(msg.content)) {
|
|
42
|
-
text = msg.content
|
|
43
|
-
.filter((c: { type: string }) => c.type === "text")
|
|
44
|
-
.map((c: { text: string }) => c.text)
|
|
45
|
-
.join(" ");
|
|
46
|
-
}
|
|
47
|
-
if (text) {
|
|
48
|
-
messages.push({ role: entry.type, content: text });
|
|
49
|
-
}
|
|
50
|
-
}
|
|
68
|
+
const parsed = parseTranscriptEntry(JSON.parse(line));
|
|
69
|
+
if (parsed) messages.push(parsed);
|
|
51
70
|
} catch {
|
|
52
71
|
/* skip malformed lines */
|
|
53
72
|
}
|
|
@@ -6,25 +6,21 @@
|
|
|
6
6
|
|
|
7
7
|
import { copyFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
8
8
|
import { resolve } from "node:path";
|
|
9
|
-
import { regenerateIfNeeded } from "../../hooks/lib/claude-md";
|
|
10
9
|
import { assets, palHome, palPkg, platform } from "../../hooks/lib/paths";
|
|
11
10
|
import { identity, raw as readPalSettings } from "../../hooks/lib/settings";
|
|
12
11
|
import {
|
|
13
12
|
addStatuslineConfig,
|
|
14
13
|
applyAttribution,
|
|
15
14
|
copyAgents,
|
|
16
|
-
copyPalDocs,
|
|
17
15
|
copySkills,
|
|
18
16
|
copyStatusline,
|
|
19
17
|
countAgents,
|
|
20
18
|
countMd,
|
|
21
19
|
countSkills,
|
|
22
|
-
generateSkillIndex,
|
|
23
20
|
loadSettingsTemplate,
|
|
24
21
|
log,
|
|
25
22
|
mergeSettings,
|
|
26
23
|
readJson,
|
|
27
|
-
scaffoldPalSettings,
|
|
28
24
|
writeJson,
|
|
29
25
|
} from "../lib";
|
|
30
26
|
|
|
@@ -68,7 +64,6 @@ log.success("Merged PAL settings into settings.json");
|
|
|
68
64
|
// --- Copy skills ---
|
|
69
65
|
const skillsDir = resolve(CLAUDE_DIR, "skills");
|
|
70
66
|
copySkills(skillsDir);
|
|
71
|
-
generateSkillIndex();
|
|
72
67
|
|
|
73
68
|
// --- Copy agents ---
|
|
74
69
|
copyAgents();
|
|
@@ -76,19 +71,6 @@ copyAgents();
|
|
|
76
71
|
// --- Copy statusline script ---
|
|
77
72
|
copyStatusline();
|
|
78
73
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
// --- Scaffold PAL settings ---
|
|
84
|
-
scaffoldPalSettings();
|
|
85
|
-
|
|
86
|
-
// --- Generate ~/.claude/AGENTS.md and symlink ~/.claude/CLAUDE.md → AGENTS.md ---
|
|
87
|
-
regenerateIfNeeded();
|
|
88
|
-
log.success("Generated ~/.config/opencode/AGENTS.md (→ ~/.claude/CLAUDE.md symlink)");
|
|
89
|
-
|
|
90
|
-
log.success("Claude Code installation complete");
|
|
91
|
-
console.log("");
|
|
92
|
-
log.info(`Skills: ${countSkills()}`);
|
|
93
|
-
log.info(`Agents: ${countAgents()}`);
|
|
94
|
-
log.info(`TELOS: ${countMd(resolve(palHome(), "telos"))} files`);
|
|
74
|
+
log.success(
|
|
75
|
+
`${countSkills()} skills · ${countAgents()} agents · ${countMd(resolve(palHome(), "telos"))} TELOS files · CLAUDE.md → AGENTS.md`
|
|
76
|
+
);
|
|
@@ -12,20 +12,17 @@ import {
|
|
|
12
12
|
writeFileSync,
|
|
13
13
|
} from "node:fs";
|
|
14
14
|
import { resolve } from "node:path";
|
|
15
|
-
import { regenerateIfNeeded } from "../../hooks/lib/claude-md";
|
|
16
15
|
import { assets, palPkg, platform } from "../../hooks/lib/paths";
|
|
17
16
|
import {
|
|
18
17
|
addCodexStatuslineConfig,
|
|
19
18
|
copySkills,
|
|
20
19
|
countSkills,
|
|
21
|
-
generateSkillIndex,
|
|
22
20
|
loadCodexHooksTemplate,
|
|
23
21
|
loadCodexRulesTemplate,
|
|
24
22
|
log,
|
|
25
23
|
mergeCodexHooks,
|
|
26
24
|
mergeCodexRules,
|
|
27
25
|
readJson,
|
|
28
|
-
scaffoldPalSettings,
|
|
29
26
|
writeJson,
|
|
30
27
|
} from "../lib";
|
|
31
28
|
|
|
@@ -86,7 +83,7 @@ const existing = readJson<Record<string, unknown>>(HOOKS_FILE, {});
|
|
|
86
83
|
const merged = mergeCodexHooks(existing, template);
|
|
87
84
|
|
|
88
85
|
writeJson(HOOKS_FILE, merged);
|
|
89
|
-
log.success(
|
|
86
|
+
log.success(`Merged PAL hooks into ${HOOKS_FILE}`);
|
|
90
87
|
|
|
91
88
|
// --- Merge allowlist rules ---
|
|
92
89
|
mkdirSync(resolve(CODEX_DIR, "rules"), { recursive: true });
|
|
@@ -102,21 +99,9 @@ log.success("Merged PAL allowlist rules into ~/.codex/rules/default.rules");
|
|
|
102
99
|
// --- Symlink skills to ~/.codex/skills/ ---
|
|
103
100
|
const codexSkillsDir = resolve(CODEX_DIR, "skills");
|
|
104
101
|
copySkills(codexSkillsDir);
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
// --- Scaffold PAL settings ---
|
|
108
|
-
scaffoldPalSettings();
|
|
109
|
-
|
|
110
|
-
// --- Generate / verify AGENTS.md symlink ---
|
|
111
|
-
regenerateIfNeeded();
|
|
112
|
-
log.success("Ensured AGENTS.md symlink at ~/.codex/AGENTS.md");
|
|
102
|
+
log.success(`${countSkills()} skills → ~/.codex/skills/`);
|
|
113
103
|
|
|
114
104
|
// --- Enable hooks in config.toml ---
|
|
115
105
|
const CONFIG_FILE = resolve(CODEX_DIR, "config.toml");
|
|
116
106
|
enableCodexHooks(CONFIG_FILE);
|
|
117
107
|
enableCodexStatusline(CONFIG_FILE);
|
|
118
|
-
|
|
119
|
-
log.success("Codex installation complete");
|
|
120
|
-
console.log("");
|
|
121
|
-
log.info(`Skills: ${countSkills()}`);
|
|
122
|
-
log.info(`Hooks: ${HOOKS_FILE}`);
|
|
@@ -5,21 +5,16 @@
|
|
|
5
5
|
* Enables ~/.copilot/instructions in VS Code chat.instructionsFilesLocations.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { mkdirSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { existsSync, mkdirSync, unlinkSync, writeFileSync } from "node:fs";
|
|
9
9
|
import { resolve } from "node:path";
|
|
10
|
-
import { writeContextDigests } from "../../hooks/handlers/context-digests";
|
|
11
|
-
import { regenerateIfNeeded } from "../../hooks/lib/claude-md";
|
|
12
10
|
import { assets, palPkg, platform } from "../../hooks/lib/paths";
|
|
13
11
|
import {
|
|
14
12
|
copyAgentsForCopilot,
|
|
15
|
-
copyPalDocs,
|
|
16
13
|
copySkills,
|
|
17
14
|
countSkills,
|
|
18
|
-
generateSkillIndex,
|
|
19
15
|
loadCopilotHooksTemplate,
|
|
20
16
|
log,
|
|
21
17
|
readJson,
|
|
22
|
-
scaffoldPalSettings,
|
|
23
18
|
vscodeSettingsFile,
|
|
24
19
|
writeJson,
|
|
25
20
|
} from "../lib";
|
|
@@ -28,6 +23,7 @@ const PKG_ROOT = palPkg().replaceAll("\\", "/");
|
|
|
28
23
|
const COPILOT_DIR = platform.copilotDir();
|
|
29
24
|
const HOOKS_DIR = resolve(COPILOT_DIR, "hooks");
|
|
30
25
|
const HOOKS_FILE = resolve(HOOKS_DIR, "pal-hooks.json");
|
|
26
|
+
const VSCODE_HOOKS_FILE = resolve(HOOKS_DIR, "pal-vscode-hooks.json");
|
|
31
27
|
|
|
32
28
|
// --- Ensure dirs ---
|
|
33
29
|
mkdirSync(HOOKS_DIR, { recursive: true });
|
|
@@ -37,34 +33,24 @@ const template = loadCopilotHooksTemplate(assets.copilotHooksTemplate(), PKG_ROO
|
|
|
37
33
|
writeFileSync(HOOKS_FILE, `${JSON.stringify(template, null, 2)}\n`, "utf-8");
|
|
38
34
|
log.success(`Written hooks to ${HOOKS_FILE}`);
|
|
39
35
|
|
|
36
|
+
// --- Retire the separate VS Code hooks file ---
|
|
37
|
+
// VS Code's own Copilot build already executes the PascalCase hooks in
|
|
38
|
+
// ~/.claude/settings.json, so registering the same events here too ran every
|
|
39
|
+
// hook twice per turn. One dual-shape block payload (see lib/agent.ts) now
|
|
40
|
+
// serves both surfaces from that single registration.
|
|
41
|
+
if (existsSync(VSCODE_HOOKS_FILE)) {
|
|
42
|
+
unlinkSync(VSCODE_HOOKS_FILE);
|
|
43
|
+
log.success("Removed pal-vscode-hooks.json (VS Code runs the Claude hooks)");
|
|
44
|
+
}
|
|
45
|
+
|
|
40
46
|
// --- Install skills ---
|
|
41
47
|
const copilotSkillsDir = resolve(COPILOT_DIR, "skills");
|
|
42
48
|
copySkills(copilotSkillsDir);
|
|
43
|
-
generateSkillIndex();
|
|
44
|
-
log.success("Installed skills to ~/.copilot/skills/");
|
|
45
49
|
|
|
46
50
|
// --- Install agents ---
|
|
47
51
|
const copilotAgentsDir = resolve(COPILOT_DIR, "agents");
|
|
48
52
|
const agentCount = copyAgentsForCopilot(copilotAgentsDir);
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
// --- Copy PAL docs ---
|
|
52
|
-
const palDocsCount = copyPalDocs();
|
|
53
|
-
log.success(`Installed ${palDocsCount} PAL docs to ~/.pal/docs/`);
|
|
54
|
-
|
|
55
|
-
// --- Scaffold PAL settings ---
|
|
56
|
-
scaffoldPalSettings();
|
|
57
|
-
|
|
58
|
-
// --- Generate AGENTS.md ---
|
|
59
|
-
regenerateIfNeeded();
|
|
60
|
-
log.success("Generated AGENTS.md");
|
|
61
|
-
|
|
62
|
-
// --- Write ~/.copilot/instructions/pal-*.instructions.md ---
|
|
63
|
-
mkdirSync(resolve(COPILOT_DIR, "instructions"), { recursive: true });
|
|
64
|
-
writeContextDigests();
|
|
65
|
-
log.success(
|
|
66
|
-
"Written ~/.copilot/instructions/pal-self-model + pal-wisdom + pal-opinions.instructions.md"
|
|
67
|
-
);
|
|
53
|
+
log.success(`${countSkills()} skills · ${agentCount} agents → ~/.copilot/`);
|
|
68
54
|
|
|
69
55
|
// --- Enable ~/.copilot/instructions in VS Code settings ---
|
|
70
56
|
const vsSettingsPath = vscodeSettingsFile();
|
|
@@ -90,8 +76,3 @@ if (vsSettingsPath) {
|
|
|
90
76
|
} else {
|
|
91
77
|
log.warn(`Could not detect VS Code settings path — ${manualHint}`);
|
|
92
78
|
}
|
|
93
|
-
|
|
94
|
-
log.success("Copilot installation complete");
|
|
95
|
-
console.log("");
|
|
96
|
-
log.info(`Skills: ${countSkills()}`);
|
|
97
|
-
log.info(`Hooks: ${HOOKS_FILE}`);
|
|
@@ -20,16 +20,22 @@ import {
|
|
|
20
20
|
|
|
21
21
|
const COPILOT_DIR = platform.copilotDir();
|
|
22
22
|
const HOOKS_FILE = resolve(COPILOT_DIR, "hooks", "pal-hooks.json");
|
|
23
|
+
const VSCODE_HOOKS_FILE = resolve(COPILOT_DIR, "hooks", "pal-vscode-hooks.json");
|
|
23
24
|
|
|
24
|
-
// --- Remove hooks
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
}
|
|
30
|
-
|
|
25
|
+
// --- Remove hooks files ---
|
|
26
|
+
function removeHooksFile(path: string, label: string): void {
|
|
27
|
+
if (!existsSync(path)) {
|
|
28
|
+
log.info(`No ${label} found, nothing to do`);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
copyFileSync(path, `${path}.bak.${Date.now()}`);
|
|
32
|
+
unlinkSync(path);
|
|
33
|
+
log.success(`Removed ${label}`);
|
|
31
34
|
}
|
|
32
35
|
|
|
36
|
+
removeHooksFile(HOOKS_FILE, "pal-hooks.json");
|
|
37
|
+
removeHooksFile(VSCODE_HOOKS_FILE, "pal-vscode-hooks.json");
|
|
38
|
+
|
|
33
39
|
// --- Remove skill symlinks ---
|
|
34
40
|
const copilotSkillsDir = resolve(COPILOT_DIR, "skills");
|
|
35
41
|
const removed = removeSkills(copilotSkillsDir);
|
|
@@ -6,22 +6,17 @@
|
|
|
6
6
|
|
|
7
7
|
import { copyFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
8
8
|
import { resolve } from "node:path";
|
|
9
|
-
import { writeContextDigests } from "../../hooks/handlers/context-digests";
|
|
10
|
-
import { regenerateIfNeeded } from "../../hooks/lib/claude-md";
|
|
11
9
|
import { assets, palPkg, platform } from "../../hooks/lib/paths";
|
|
12
10
|
import {
|
|
13
11
|
addStatuslineConfig,
|
|
14
12
|
copyAgentsForCursor,
|
|
15
|
-
copyPalDocs,
|
|
16
13
|
copySkills,
|
|
17
14
|
copyStatusline,
|
|
18
15
|
countSkills,
|
|
19
|
-
generateSkillIndex,
|
|
20
16
|
loadCursorHooksTemplate,
|
|
21
17
|
log,
|
|
22
18
|
mergeCursorHooks,
|
|
23
19
|
readJson,
|
|
24
|
-
scaffoldPalSettings,
|
|
25
20
|
writeJson,
|
|
26
21
|
} from "../lib";
|
|
27
22
|
|
|
@@ -45,24 +40,16 @@ const existing = readJson<Record<string, unknown>>(HOOKS_FILE, {});
|
|
|
45
40
|
const merged = mergeCursorHooks(existing, template);
|
|
46
41
|
|
|
47
42
|
writeJson(HOOKS_FILE, merged);
|
|
48
|
-
log.success(
|
|
43
|
+
log.success(`Merged PAL hooks into ${HOOKS_FILE}`);
|
|
49
44
|
|
|
50
45
|
// --- Symlink skills to ~/.cursor/skills/ ---
|
|
51
46
|
const cursorSkillsDir = resolve(CURSOR_DIR, "skills");
|
|
52
47
|
copySkills(cursorSkillsDir);
|
|
53
|
-
generateSkillIndex();
|
|
54
48
|
|
|
55
49
|
// --- Copy agents to ~/.cursor/agents/ ---
|
|
56
50
|
const cursorAgentsDir = resolve(CURSOR_DIR, "agents");
|
|
57
51
|
const agentCount = copyAgentsForCursor(cursorAgentsDir);
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
// --- Copy PAL system docs ---
|
|
61
|
-
const palDocsCount = copyPalDocs();
|
|
62
|
-
log.success(`Installed ${palDocsCount} PAL docs to ~/.pal/docs/`);
|
|
63
|
-
|
|
64
|
-
// --- Scaffold PAL settings ---
|
|
65
|
-
scaffoldPalSettings();
|
|
52
|
+
log.success(`${countSkills()} skills · ${agentCount} agents → ~/.cursor/`);
|
|
66
53
|
|
|
67
54
|
// --- Statusline script + cli-config.json statusLine ---
|
|
68
55
|
copyStatusline("cursor");
|
|
@@ -77,21 +64,6 @@ const cliConfig = readJson<Record<string, unknown>>(CLI_CONFIG, {});
|
|
|
77
64
|
writeJson(CLI_CONFIG, addStatuslineConfig(cliConfig, "cursor"));
|
|
78
65
|
log.success("Merged statusLine into cli-config.json");
|
|
79
66
|
|
|
80
|
-
// --- Generate AGENTS.md ---
|
|
81
|
-
regenerateIfNeeded();
|
|
82
|
-
log.success("Generated AGENTS.md");
|
|
83
|
-
|
|
84
|
-
// --- Write ~/.cursor/rules/pal-*.mdc ---
|
|
85
|
-
mkdirSync(resolve(CURSOR_DIR, "rules"), { recursive: true });
|
|
86
|
-
writeContextDigests();
|
|
87
|
-
log.success(
|
|
88
|
-
"Written ~/.cursor/rules/pal-self-model.mdc + pal-wisdom.mdc + pal-opinions.mdc"
|
|
89
|
-
);
|
|
90
|
-
|
|
91
|
-
log.success("Cursor installation complete");
|
|
92
|
-
console.log("");
|
|
93
|
-
log.info(`Skills: ${countSkills()}`);
|
|
94
|
-
log.info(`Hooks: ${HOOKS_FILE}`);
|
|
95
67
|
log.info(
|
|
96
68
|
"Note: Cursor tool matchers may need tuning — verify hook behavior after first use"
|
|
97
69
|
);
|
package/src/targets/lib.ts
CHANGED
|
@@ -741,6 +741,23 @@ export function removePalDocs(): void {
|
|
|
741
741
|
|
|
742
742
|
const PAL_SKILLS_DIR = resolve(palHome(), "skills");
|
|
743
743
|
|
|
744
|
+
/**
|
|
745
|
+
* Run one step of a bulk install, naming it only when it fails.
|
|
746
|
+
*
|
|
747
|
+
* Installs handle dozens of skills and agents; a line each buries the paths,
|
|
748
|
+
* backups and warnings that a reader actually has to act on. Returning false
|
|
749
|
+
* instead of throwing also keeps one unlinkable skill from aborting the rest.
|
|
750
|
+
*/
|
|
751
|
+
function reportOnlyOnFailure(label: string, install: () => void): boolean {
|
|
752
|
+
try {
|
|
753
|
+
install();
|
|
754
|
+
return true;
|
|
755
|
+
} catch (e) {
|
|
756
|
+
log.warn(`Could not install ${label} — ${(e as Error).message}`);
|
|
757
|
+
return false;
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
744
761
|
/**
|
|
745
762
|
* Install PAL skills by symlinking:
|
|
746
763
|
* ~/.pal/skills/<name> → <repo>/assets/skills/<name> (source of truth)
|
|
@@ -762,16 +779,15 @@ export function copySkills(claudeSkillsDir: string): number {
|
|
|
762
779
|
const srcDir = resolve(skillsDir, name);
|
|
763
780
|
if (!existsSync(resolve(srcDir, "SKILL.md"))) continue;
|
|
764
781
|
|
|
765
|
-
// ~/.pal/skills/<name> → <repo>/assets/skills/<name>
|
|
766
782
|
const palLink = resolve(PAL_SKILLS_DIR, name);
|
|
767
|
-
ensureSymlink(palLink, srcDir, linkType);
|
|
768
|
-
|
|
769
|
-
// ~/.claude/skills/<name> → ~/.pal/skills/<name>
|
|
770
783
|
const claudeLink = resolve(claudeSkillsDir, name);
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
784
|
+
const linked = reportOnlyOnFailure(`skill ${name}`, () => {
|
|
785
|
+
// ~/.pal/skills/<name> → <repo>/assets/skills/<name>
|
|
786
|
+
ensureSymlink(palLink, srcDir, linkType);
|
|
787
|
+
// ~/.claude/skills/<name> → ~/.pal/skills/<name>
|
|
788
|
+
ensureSymlink(claudeLink, palLink, linkType);
|
|
789
|
+
});
|
|
790
|
+
if (linked) count++;
|
|
775
791
|
}
|
|
776
792
|
|
|
777
793
|
// ~/.agents/skills/ → ~/.pal/skills/
|
|
@@ -1000,14 +1016,16 @@ function installAgents(targetDir: string, platform: AgentPlatform): number {
|
|
|
1000
1016
|
let count = 0;
|
|
1001
1017
|
|
|
1002
1018
|
for (const file of readdirSync(agentsDir).filter((f) => f.endsWith(".md"))) {
|
|
1003
|
-
const
|
|
1004
|
-
|
|
1005
|
-
resolve(
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1019
|
+
const name = file.replace(/\.md$/, "");
|
|
1020
|
+
const installed = reportOnlyOnFailure(`${platform} agent ${name}`, () => {
|
|
1021
|
+
const content = readFileSync(resolve(agentsDir, file), "utf-8");
|
|
1022
|
+
writeFileSync(
|
|
1023
|
+
resolve(targetDir, file),
|
|
1024
|
+
extractAgentForPlatform(content, platform),
|
|
1025
|
+
"utf-8"
|
|
1026
|
+
);
|
|
1027
|
+
});
|
|
1028
|
+
if (installed) count++;
|
|
1011
1029
|
}
|
|
1012
1030
|
return count;
|
|
1013
1031
|
}
|
|
@@ -1375,7 +1393,6 @@ export function generateSkillIndex(): number {
|
|
|
1375
1393
|
const stateDir = resolve(palHome(), "memory", "state");
|
|
1376
1394
|
mkdirSync(stateDir, { recursive: true });
|
|
1377
1395
|
writeJson(resolve(stateDir, "skill-index.json"), index);
|
|
1378
|
-
log.info(`Skill index: ${index.totalSkills} skills indexed`);
|
|
1379
1396
|
|
|
1380
1397
|
return index.totalSkills;
|
|
1381
1398
|
}
|
|
@@ -12,18 +12,9 @@ import {
|
|
|
12
12
|
writeFileSync,
|
|
13
13
|
} from "node:fs";
|
|
14
14
|
import { resolve } from "node:path";
|
|
15
|
-
import { regenerateIfNeeded } from "../../hooks/lib/claude-md";
|
|
16
15
|
import { palPkg, platform } from "../../hooks/lib/paths";
|
|
17
16
|
import { getSemiStaticSources } from "../../hooks/lib/semi-static";
|
|
18
|
-
import {
|
|
19
|
-
copyAgentsForOpencode,
|
|
20
|
-
copyPalDocs,
|
|
21
|
-
copySkills,
|
|
22
|
-
countSkills,
|
|
23
|
-
generateSkillIndex,
|
|
24
|
-
log,
|
|
25
|
-
writeJson,
|
|
26
|
-
} from "../lib";
|
|
17
|
+
import { copyAgentsForOpencode, copySkills, countSkills, log, writeJson } from "../lib";
|
|
27
18
|
|
|
28
19
|
const PKG_ROOT = palPkg();
|
|
29
20
|
const OC_GLOBAL_DIR = platform.opencodeDir();
|
|
@@ -55,7 +46,6 @@ if (!existsSync(pkgPath)) {
|
|
|
55
46
|
|
|
56
47
|
try {
|
|
57
48
|
Bun.spawnSync(["bun", "install", "--silent"], { cwd: OC_PLUGINS_DIR });
|
|
58
|
-
log.success("Installed plugin dependencies");
|
|
59
49
|
} catch {
|
|
60
50
|
log.warn(`Could not install plugin deps — run 'bun install' in ${OC_PLUGINS_DIR}`);
|
|
61
51
|
}
|
|
@@ -63,22 +53,13 @@ try {
|
|
|
63
53
|
// --- 3. Install skills into ~/.pal/skills/ ---
|
|
64
54
|
const claudeSkillsDir = resolve(platform.claudeDir(), "skills");
|
|
65
55
|
copySkills(claudeSkillsDir);
|
|
66
|
-
generateSkillIndex();
|
|
67
|
-
log.success("Installed skills to ~/.pal/skills/");
|
|
68
56
|
|
|
69
57
|
// --- 4. Install agents into ~/.config/opencode/agents/ ---
|
|
70
58
|
const ocAgentsDir = resolve(OC_GLOBAL_DIR, "agents");
|
|
71
|
-
copyAgentsForOpencode(ocAgentsDir);
|
|
72
|
-
|
|
73
|
-
// --- 5. Copy PAL system docs ---
|
|
74
|
-
const palDocsCount = copyPalDocs();
|
|
75
|
-
log.success(`Installed ${palDocsCount} PAL docs to ~/.pal/docs/`);
|
|
76
|
-
|
|
77
|
-
// --- 6. Generate ~/.config/opencode/AGENTS.md ---
|
|
78
|
-
regenerateIfNeeded();
|
|
79
|
-
log.success("Generated ~/.config/opencode/AGENTS.md");
|
|
59
|
+
const agentCount = copyAgentsForOpencode(ocAgentsDir);
|
|
60
|
+
log.success(`${countSkills()} skills · ${agentCount} agents → ~/.config/opencode/`);
|
|
80
61
|
|
|
81
|
-
// ---
|
|
62
|
+
// --- 5. Add semi-static digest files to instructions[] in config.json ---
|
|
82
63
|
const configPath = resolve(OC_GLOBAL_DIR, "config.json");
|
|
83
64
|
const staticFiles = getSemiStaticSources().map((s) => s.path);
|
|
84
65
|
let ocConfig: Record<string, unknown> = {};
|
|
@@ -94,11 +75,3 @@ const existingInstructions = Array.isArray(ocConfig.instructions)
|
|
|
94
75
|
: [];
|
|
95
76
|
ocConfig.instructions = [...new Set([...existingInstructions, ...staticFiles])];
|
|
96
77
|
writeFileSync(configPath, `${JSON.stringify(ocConfig, null, 2)}\n`, "utf-8");
|
|
97
|
-
log.success(
|
|
98
|
-
`Updated config.json: ${(ocConfig.instructions as string[]).length} instructions`
|
|
99
|
-
);
|
|
100
|
-
|
|
101
|
-
log.success("opencode installation complete");
|
|
102
|
-
console.log("");
|
|
103
|
-
log.info(`Plugin: ${pluginDst}`);
|
|
104
|
-
log.info(`Skills: ${countSkills()} (native via ~/.pal/skills/)`);
|