portable-agent-layer 0.63.0 → 0.63.2
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.copilot.json +12 -6
- package/assets/templates/hooks.vscode.json +37 -0
- package/package.json +1 -1
- package/src/cli/index.ts +84 -26
- package/src/hooks/SecurityValidator.ts +83 -32
- package/src/hooks/SkillGuard.ts +8 -12
- package/src/hooks/StopOrchestrator.ts +7 -4
- 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 +21 -22
- package/src/targets/cursor/install.ts +2 -30
- package/src/targets/cursor/uninstall.ts +4 -16
- package/src/targets/lib.ts +57 -17
- package/src/targets/opencode/install.ts +4 -31
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}`);
|
|
@@ -7,11 +7,11 @@
|
|
|
7
7
|
import { copyFileSync, existsSync, lstatSync, readlinkSync, unlinkSync } from "node:fs";
|
|
8
8
|
import { resolve } from "node:path";
|
|
9
9
|
import { platform } from "../../hooks/lib/paths";
|
|
10
|
-
import { copilotFilename, getSemiStaticSources } from "../../hooks/lib/semi-static";
|
|
11
10
|
import {
|
|
12
11
|
log,
|
|
13
12
|
readJson,
|
|
14
13
|
removeAgentsFromCopilot,
|
|
14
|
+
removePalContextFiles,
|
|
15
15
|
removePalDocs,
|
|
16
16
|
removeSkills,
|
|
17
17
|
vscodeSettingsFile,
|
|
@@ -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);
|
|
@@ -49,20 +55,13 @@ if (removedAgents.length > 0) {
|
|
|
49
55
|
removePalDocs();
|
|
50
56
|
|
|
51
57
|
// --- Remove ~/.copilot/instructions/pal-*.instructions.md ---
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
// pal-session.instructions.md is written by LoadContext (not a semi-static source)
|
|
60
|
-
try {
|
|
61
|
-
unlinkSync(resolve(COPILOT_DIR, "instructions", "pal-session.instructions.md"));
|
|
62
|
-
} catch {
|
|
63
|
-
/* gone */
|
|
64
|
-
}
|
|
65
|
-
log.success("Removed ~/.copilot/instructions/pal-*.instructions.md");
|
|
58
|
+
const removedInstructions = removePalContextFiles(
|
|
59
|
+
resolve(COPILOT_DIR, "instructions"),
|
|
60
|
+
".instructions.md"
|
|
61
|
+
);
|
|
62
|
+
log.success(
|
|
63
|
+
`Removed ${removedInstructions.length} ~/.copilot/instructions/pal-*.instructions.md`
|
|
64
|
+
);
|
|
66
65
|
|
|
67
66
|
// --- Backward compat: remove old copilot-instructions.md symlink if present ---
|
|
68
67
|
const legacyPath = resolve(COPILOT_DIR, "copilot-instructions.md");
|
|
@@ -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
|
);
|