@tekmidian/pai 0.3.1 → 0.4.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.
Files changed (61) hide show
  1. package/dist/cli/index.mjs +352 -33
  2. package/dist/cli/index.mjs.map +1 -1
  3. package/dist/hooks/capture-all-events.mjs +238 -0
  4. package/dist/hooks/capture-all-events.mjs.map +7 -0
  5. package/dist/hooks/capture-session-summary.mjs +198 -0
  6. package/dist/hooks/capture-session-summary.mjs.map +7 -0
  7. package/dist/hooks/capture-tool-output.mjs +105 -0
  8. package/dist/hooks/capture-tool-output.mjs.map +7 -0
  9. package/dist/hooks/cleanup-session-files.mjs +129 -0
  10. package/dist/hooks/cleanup-session-files.mjs.map +7 -0
  11. package/dist/hooks/context-compression-hook.mjs +283 -0
  12. package/dist/hooks/context-compression-hook.mjs.map +7 -0
  13. package/dist/hooks/initialize-session.mjs +206 -0
  14. package/dist/hooks/initialize-session.mjs.map +7 -0
  15. package/dist/hooks/load-core-context.mjs +110 -0
  16. package/dist/hooks/load-core-context.mjs.map +7 -0
  17. package/dist/hooks/load-project-context.mjs +548 -0
  18. package/dist/hooks/load-project-context.mjs.map +7 -0
  19. package/dist/hooks/security-validator.mjs +159 -0
  20. package/dist/hooks/security-validator.mjs.map +7 -0
  21. package/dist/hooks/stop-hook.mjs +625 -0
  22. package/dist/hooks/stop-hook.mjs.map +7 -0
  23. package/dist/hooks/subagent-stop-hook.mjs +152 -0
  24. package/dist/hooks/subagent-stop-hook.mjs.map +7 -0
  25. package/dist/hooks/sync-todo-to-md.mjs +322 -0
  26. package/dist/hooks/sync-todo-to-md.mjs.map +7 -0
  27. package/dist/hooks/update-tab-on-action.mjs +90 -0
  28. package/dist/hooks/update-tab-on-action.mjs.map +7 -0
  29. package/dist/hooks/update-tab-titles.mjs +55 -0
  30. package/dist/hooks/update-tab-titles.mjs.map +7 -0
  31. package/package.json +4 -2
  32. package/scripts/build-hooks.mjs +51 -0
  33. package/src/hooks/pre-compact.sh +4 -0
  34. package/src/hooks/session-stop.sh +4 -0
  35. package/src/hooks/ts/capture-all-events.ts +179 -0
  36. package/src/hooks/ts/lib/detect-environment.ts +53 -0
  37. package/src/hooks/ts/lib/metadata-extraction.ts +144 -0
  38. package/src/hooks/ts/lib/pai-paths.ts +124 -0
  39. package/src/hooks/ts/lib/project-utils.ts +914 -0
  40. package/src/hooks/ts/post-tool-use/capture-tool-output.ts +78 -0
  41. package/src/hooks/ts/post-tool-use/sync-todo-to-md.ts +230 -0
  42. package/src/hooks/ts/post-tool-use/update-tab-on-action.ts +145 -0
  43. package/src/hooks/ts/pre-compact/context-compression-hook.ts +155 -0
  44. package/src/hooks/ts/pre-tool-use/security-validator.ts +258 -0
  45. package/src/hooks/ts/session-end/capture-session-summary.ts +185 -0
  46. package/src/hooks/ts/session-start/initialize-session.ts +155 -0
  47. package/src/hooks/ts/session-start/load-core-context.ts +104 -0
  48. package/src/hooks/ts/session-start/load-project-context.ts +394 -0
  49. package/src/hooks/ts/stop/stop-hook.ts +407 -0
  50. package/src/hooks/ts/subagent-stop/subagent-stop-hook.ts +212 -0
  51. package/src/hooks/ts/user-prompt/cleanup-session-files.ts +45 -0
  52. package/src/hooks/ts/user-prompt/update-tab-titles.ts +88 -0
  53. package/tab-color-command.sh +24 -0
  54. package/templates/ai-steering-rules.template.md +58 -0
  55. package/templates/pai-skill.template.md +24 -0
  56. package/templates/skills/createskill-skill.template.md +78 -0
  57. package/templates/skills/history-system.template.md +371 -0
  58. package/templates/skills/hook-system.template.md +913 -0
  59. package/templates/skills/sessions-skill.template.md +102 -0
  60. package/templates/skills/skill-system.template.md +214 -0
  61. package/templates/skills/terminal-tabs.template.md +120 -0
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Build TypeScript hooks into standalone .mjs files using esbuild.
4
+ *
5
+ * Each hook is fully self-contained — lib/ dependencies are inlined.
6
+ * Output: dist/hooks/<name>.mjs with #!/usr/bin/env node shebang.
7
+ */
8
+
9
+ import { buildSync } from "esbuild";
10
+ import { readdirSync, statSync, chmodSync } from "fs";
11
+ import { join, relative, basename } from "path";
12
+
13
+ const HOOKS_SRC = "src/hooks/ts";
14
+ const HOOKS_OUT = "dist/hooks";
15
+
16
+ // Collect all .ts entry points (skip lib/ — those are bundled into each hook)
17
+ function collectEntryPoints(dir) {
18
+ const entries = [];
19
+ for (const name of readdirSync(dir)) {
20
+ const full = join(dir, name);
21
+ if (name === "lib") continue;
22
+ if (statSync(full).isDirectory()) {
23
+ entries.push(...collectEntryPoints(full));
24
+ } else if (name.endsWith(".ts")) {
25
+ entries.push(full);
26
+ }
27
+ }
28
+ return entries;
29
+ }
30
+
31
+ const entryPoints = collectEntryPoints(HOOKS_SRC);
32
+
33
+ console.log(`Building ${entryPoints.length} hooks with esbuild...`);
34
+
35
+ for (const entry of entryPoints) {
36
+ const outfile = join(HOOKS_OUT, basename(entry).replace(/\.ts$/, ".mjs"));
37
+
38
+ buildSync({
39
+ entryPoints: [entry],
40
+ bundle: true,
41
+ platform: "node",
42
+ target: "node20",
43
+ format: "esm",
44
+ outfile,
45
+ sourcemap: true,
46
+ });
47
+
48
+ chmodSync(outfile, 0o755);
49
+ }
50
+
51
+ console.log(`✔ ${entryPoints.length} hooks built to ${HOOKS_OUT}/`);
@@ -8,6 +8,10 @@
8
8
 
9
9
  PAI_OS="pai"
10
10
 
11
+ # Set tab color to working state while compacting
12
+ TAB_COLOR="${PAI_DIR:-$HOME/.claude}/tab-color-command.sh"
13
+ [[ -x "$TAB_COLOR" ]] && "$TAB_COLOR" working
14
+
11
15
  # Bail gracefully if pai is not installed
12
16
  command -v "$PAI_OS" &>/dev/null || exit 0
13
17
  command -v sqlite3 &>/dev/null || exit 0
@@ -90,4 +90,8 @@ fi
90
90
 
91
91
  "$PAI_OS" session handover "$PROJECT_SLUG" latest 2>/dev/null || true
92
92
 
93
+ # Set tab color to completed state when session ends
94
+ TAB_COLOR="${PAI_DIR:-$HOME/.claude}/tab-color-command.sh"
95
+ [[ -x "$TAB_COLOR" ]] && "$TAB_COLOR" completed
96
+
93
97
  exit 0
@@ -0,0 +1,179 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Capture All Events Hook
4
+ * Captures ALL Claude Code hook events (not just tools) to JSONL
5
+ * This replaces the Python send_event.py hook
6
+ * Enhanced with agent instance metadata extraction
7
+ */
8
+
9
+ import { readFileSync, appendFileSync, mkdirSync, existsSync, writeFileSync } from 'fs';
10
+ import { join } from 'path';
11
+ import { PAI_DIR, HISTORY_DIR } from './lib/pai-paths';
12
+ import { enrichEventWithAgentMetadata, isAgentSpawningCall } from './lib/metadata-extraction';
13
+
14
+ interface HookEvent {
15
+ source_app: string;
16
+ session_id: string;
17
+ hook_event_type: string;
18
+ payload: Record<string, any>;
19
+ timestamp: number;
20
+ timestamp_local: string;
21
+ }
22
+
23
+ // Get local timestamp using system timezone
24
+ function getLocalTimestamp(): string {
25
+ const date = new Date();
26
+ const tz = process.env.TIME_ZONE || Intl.DateTimeFormat().resolvedOptions().timeZone;
27
+ const localDate = new Date(date.toLocaleString('en-US', { timeZone: tz }));
28
+
29
+ const year = localDate.getFullYear();
30
+ const month = String(localDate.getMonth() + 1).padStart(2, '0');
31
+ const day = String(localDate.getDate()).padStart(2, '0');
32
+ const hours = String(localDate.getHours()).padStart(2, '0');
33
+ const minutes = String(localDate.getMinutes()).padStart(2, '0');
34
+ const seconds = String(localDate.getSeconds()).padStart(2, '0');
35
+
36
+ return `${year}-${month}-${day} ${hours}:${minutes}:${seconds} ${tz}`;
37
+ }
38
+
39
+ // Get current events file path
40
+ function getEventsFilePath(): string {
41
+ const now = new Date();
42
+ const tz = process.env.TIME_ZONE || Intl.DateTimeFormat().resolvedOptions().timeZone;
43
+ const localDate = new Date(now.toLocaleString('en-US', { timeZone: tz }));
44
+ const year = localDate.getFullYear();
45
+ const month = String(localDate.getMonth() + 1).padStart(2, '0');
46
+ const day = String(localDate.getDate()).padStart(2, '0');
47
+
48
+ const monthDir = join(HISTORY_DIR, 'raw-outputs', `${year}-${month}`);
49
+
50
+ // Ensure directory exists
51
+ if (!existsSync(monthDir)) {
52
+ mkdirSync(monthDir, { recursive: true });
53
+ }
54
+
55
+ return join(monthDir, `${year}-${month}-${day}_all-events.jsonl`);
56
+ }
57
+
58
+ // Session-to-agent mapping functions
59
+ function getSessionMappingFile(): string {
60
+ return join(PAI_DIR, 'agent-sessions.json');
61
+ }
62
+
63
+ function getAgentForSession(sessionId: string): string {
64
+ try {
65
+ const mappingFile = getSessionMappingFile();
66
+ if (existsSync(mappingFile)) {
67
+ const mappings = JSON.parse(readFileSync(mappingFile, 'utf-8'));
68
+ return mappings[sessionId] || 'pai';
69
+ }
70
+ } catch (error) {
71
+ // Ignore errors, default to pai
72
+ }
73
+ return 'pai';
74
+ }
75
+
76
+ function setAgentForSession(sessionId: string, agentName: string): void {
77
+ try {
78
+ const mappingFile = getSessionMappingFile();
79
+ let mappings: Record<string, string> = {};
80
+
81
+ if (existsSync(mappingFile)) {
82
+ mappings = JSON.parse(readFileSync(mappingFile, 'utf-8'));
83
+ }
84
+
85
+ mappings[sessionId] = agentName;
86
+ writeFileSync(mappingFile, JSON.stringify(mappings, null, 2), 'utf-8');
87
+ } catch (error) {
88
+ // Silently fail - don't block
89
+ }
90
+ }
91
+
92
+ async function main() {
93
+ try {
94
+ // Get event type from command line args
95
+ const args = process.argv.slice(2);
96
+ const eventTypeIndex = args.indexOf('--event-type');
97
+
98
+ if (eventTypeIndex === -1) {
99
+ console.error('Missing --event-type argument');
100
+ process.exit(0); // Don't block Claude Code
101
+ }
102
+
103
+ const eventType = args[eventTypeIndex + 1];
104
+
105
+ // Read hook data from stdin
106
+ const chunks: Buffer[] = [];
107
+ for await (const chunk of process.stdin) {
108
+ chunks.push(chunk);
109
+ }
110
+ const stdinData = Buffer.concat(chunks).toString('utf-8');
111
+ const hookData = JSON.parse(stdinData);
112
+
113
+ // Detect agent type from session mapping or payload
114
+ const sessionId = hookData.session_id || 'main';
115
+ let agentName = getAgentForSession(sessionId);
116
+
117
+ // If this is a Task tool launching a subagent, update the session mapping
118
+ if (hookData.tool_name === 'Task' && hookData.tool_input?.subagent_type) {
119
+ agentName = hookData.tool_input.subagent_type;
120
+ setAgentForSession(sessionId, agentName);
121
+ }
122
+ // If this is a SubagentStop or Stop event, reset to pai
123
+ else if (eventType === 'SubagentStop' || eventType === 'Stop') {
124
+ agentName = 'pai';
125
+ setAgentForSession(sessionId, 'pai');
126
+ }
127
+ // Check if CLAUDE_CODE_AGENT env variable is set (for subagents)
128
+ else if (process.env.CLAUDE_CODE_AGENT) {
129
+ agentName = process.env.CLAUDE_CODE_AGENT;
130
+ setAgentForSession(sessionId, agentName);
131
+ }
132
+ // Check if agent type is in the payload (alternative detection method)
133
+ else if (hookData.agent_type) {
134
+ agentName = hookData.agent_type;
135
+ setAgentForSession(sessionId, agentName);
136
+ }
137
+ // Check if this is from a subagent based on cwd containing 'agent'
138
+ else if (hookData.cwd && hookData.cwd.includes('/agents/')) {
139
+ // Extract agent name from path like "/agents/designer/"
140
+ const agentMatch = hookData.cwd.match(/\/agents\/([^\/]+)/);
141
+ if (agentMatch) {
142
+ agentName = agentMatch[1];
143
+ setAgentForSession(sessionId, agentName);
144
+ }
145
+ }
146
+
147
+ // Create base event object
148
+ let event: HookEvent = {
149
+ source_app: agentName,
150
+ session_id: hookData.session_id || 'main',
151
+ hook_event_type: eventType,
152
+ payload: hookData,
153
+ timestamp: Date.now(),
154
+ timestamp_local: getLocalTimestamp()
155
+ };
156
+
157
+ // Enrich with agent instance metadata if this is a Task tool call
158
+ if (isAgentSpawningCall(hookData.tool_name, hookData.tool_input)) {
159
+ event = enrichEventWithAgentMetadata(
160
+ event,
161
+ hookData.tool_input,
162
+ hookData.description
163
+ );
164
+ }
165
+
166
+ // Append to events file
167
+ const eventsFile = getEventsFilePath();
168
+ const jsonLine = JSON.stringify(event) + '\n';
169
+ appendFileSync(eventsFile, jsonLine, 'utf-8');
170
+
171
+ } catch (error) {
172
+ // Silently fail - don't block Claude Code
173
+ console.error('Event capture error:', error);
174
+ }
175
+
176
+ process.exit(0);
177
+ }
178
+
179
+ main();
@@ -0,0 +1,53 @@
1
+ /**
2
+ * detect-environment.ts
3
+ *
4
+ * Environment detection utilities for PAI hooks.
5
+ *
6
+ * Determines if the system is running in a remote environment (SSH, cloud, etc.)
7
+ * to conditionally adjust hook behaviour.
8
+ *
9
+ * Detection Logic (in priority order):
10
+ * 1. Check PAI_ENVIRONMENT env var (explicit override)
11
+ * 2. Check for SSH indicators (SSH_CLIENT, SSH_TTY)
12
+ * 3. Default to local environment
13
+ */
14
+
15
+ /**
16
+ * Check if we're running in an SSH session
17
+ * Looks for SSH_CLIENT or SSH_TTY environment variables
18
+ */
19
+ function isSSHSession(): boolean {
20
+ return !!(process.env.SSH_CLIENT || process.env.SSH_TTY);
21
+ }
22
+
23
+ /**
24
+ * Main detection function - determines if we're in a remote environment
25
+ *
26
+ * Returns:
27
+ * - true: Remote environment
28
+ * - false: Local environment
29
+ *
30
+ * Detection priority:
31
+ * 1. PAI_ENVIRONMENT === 'remote' → true
32
+ * 2. PAI_ENVIRONMENT === 'local' → false
33
+ * 3. SSH_CLIENT or SSH_TTY set → true
34
+ * 4. Otherwise → false (local)
35
+ */
36
+ export function isRemoteEnvironment(): boolean {
37
+ // 1. Check explicit environment override
38
+ const paiEnv = process.env.PAI_ENVIRONMENT?.toLowerCase();
39
+ if (paiEnv === 'remote') {
40
+ return true;
41
+ }
42
+ if (paiEnv === 'local') {
43
+ return false;
44
+ }
45
+
46
+ // 2. Check for SSH session
47
+ if (isSSHSession()) {
48
+ return true;
49
+ }
50
+
51
+ // 3. Default to local environment
52
+ return false;
53
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Metadata Extraction Library for UOCS Enhancement
3
+ *
4
+ * Extracts agent instance IDs, parent-child relationships, and session info
5
+ * from Task tool calls and other tool inputs.
6
+ *
7
+ * Design Philosophy: Optional extraction with graceful fallbacks
8
+ * - If instance IDs are present in descriptions/prompts, extract them
9
+ * - If not present, fall back to agent type only
10
+ * - Never fail - always return usable metadata
11
+ */
12
+
13
+ export interface AgentInstanceMetadata {
14
+ agent_instance_id?: string; // "perplexity-researcher-1" (full ID)
15
+ agent_type?: string; // "perplexity-researcher" (base type)
16
+ instance_number?: number; // 1 (sequence number)
17
+ parent_session_id?: string; // Session that spawned this agent
18
+ parent_task_id?: string; // Task ID that spawned this agent
19
+ }
20
+
21
+ /**
22
+ * Extract agent instance ID from Task tool input
23
+ *
24
+ * Looks for patterns in priority order:
25
+ * 1. [agent-type-N] in description (e.g., "Research topic [perplexity-researcher-1]")
26
+ * 2. [AGENT_INSTANCE: agent-type-N] in prompt
27
+ * 3. subagent_type field (fallback to just type, no instance number)
28
+ *
29
+ * @param toolInput The tool input object from PreToolUse/PostToolUse hooks
30
+ * @param description Optional description field from tool input
31
+ * @returns Metadata object with extracted information
32
+ */
33
+ export function extractAgentInstanceId(
34
+ toolInput: any,
35
+ description?: string
36
+ ): AgentInstanceMetadata {
37
+ const result: AgentInstanceMetadata = {};
38
+
39
+ // Strategy 1: Extract from description [agent-type-N]
40
+ // Example: "Research consumer complaints [perplexity-researcher-1]"
41
+ if (description) {
42
+ const descMatch = description.match(/\[([a-z-]+-researcher)-(\d+)\]/);
43
+ if (descMatch) {
44
+ result.agent_type = descMatch[1];
45
+ result.instance_number = parseInt(descMatch[2], 10);
46
+ result.agent_instance_id = `${result.agent_type}-${result.instance_number}`;
47
+ }
48
+ }
49
+
50
+ // Strategy 2: Extract from prompt [AGENT_INSTANCE: ...]
51
+ // Example: "[AGENT_INSTANCE: perplexity-researcher-1]"
52
+ if (!result.agent_instance_id && toolInput?.prompt && typeof toolInput.prompt === 'string') {
53
+ const promptMatch = toolInput.prompt.match(/\[AGENT_INSTANCE:\s*([^\]]+)\]/);
54
+ if (promptMatch) {
55
+ result.agent_instance_id = promptMatch[1].trim();
56
+
57
+ // Parse agent type and instance number from ID
58
+ const parts = result.agent_instance_id.match(/^([a-z-]+)-(\d+)$/);
59
+ if (parts) {
60
+ result.agent_type = parts[1];
61
+ result.instance_number = parseInt(parts[2], 10);
62
+ }
63
+ }
64
+ }
65
+
66
+ // Strategy 3: Extract parent session from prompt
67
+ // Example: "[PARENT_SESSION: b7062b5a-03d3-4168-9555-a748e0b2efa3]"
68
+ if (toolInput?.prompt && typeof toolInput.prompt === 'string') {
69
+ const parentSessionMatch = toolInput.prompt.match(/\[PARENT_SESSION:\s*([^\]]+)\]/);
70
+ if (parentSessionMatch) {
71
+ result.parent_session_id = parentSessionMatch[1].trim();
72
+ }
73
+
74
+ // Extract parent task from prompt
75
+ // Example: "[PARENT_TASK: research_1731445892345]"
76
+ const parentTaskMatch = toolInput.prompt.match(/\[PARENT_TASK:\s*([^\]]+)\]/);
77
+ if (parentTaskMatch) {
78
+ result.parent_task_id = parentTaskMatch[1].trim();
79
+ }
80
+ }
81
+
82
+ // Strategy 4: Fallback to subagent_type if available (no instance number)
83
+ // This ensures we at least capture the agent type even without instance IDs
84
+ if (!result.agent_type && toolInput?.subagent_type) {
85
+ result.agent_type = toolInput.subagent_type;
86
+ }
87
+
88
+ return result;
89
+ }
90
+
91
+ /**
92
+ * Enrich event with agent metadata
93
+ *
94
+ * Takes a base event object and adds agent instance metadata to it.
95
+ * Returns a new object with merged metadata.
96
+ *
97
+ * @param event Base event object (from PreToolUse/PostToolUse)
98
+ * @param toolInput Tool input object
99
+ * @param description Optional description field
100
+ * @returns Enriched event with agent metadata
101
+ */
102
+ export function enrichEventWithAgentMetadata(
103
+ event: any,
104
+ toolInput: any,
105
+ description?: string
106
+ ): any {
107
+ const metadata = extractAgentInstanceId(toolInput, description);
108
+
109
+ // Only add fields that have values (keep events clean)
110
+ const enrichedEvent = { ...event };
111
+
112
+ if (metadata.agent_instance_id) {
113
+ enrichedEvent.agent_instance_id = metadata.agent_instance_id;
114
+ }
115
+
116
+ if (metadata.agent_type) {
117
+ enrichedEvent.agent_type = metadata.agent_type;
118
+ }
119
+
120
+ if (metadata.instance_number !== undefined) {
121
+ enrichedEvent.instance_number = metadata.instance_number;
122
+ }
123
+
124
+ if (metadata.parent_session_id) {
125
+ enrichedEvent.parent_session_id = metadata.parent_session_id;
126
+ }
127
+
128
+ if (metadata.parent_task_id) {
129
+ enrichedEvent.parent_task_id = metadata.parent_task_id;
130
+ }
131
+
132
+ return enrichedEvent;
133
+ }
134
+
135
+ /**
136
+ * Check if a tool call is spawning a subagent
137
+ *
138
+ * @param toolName Name of the tool being called
139
+ * @param toolInput Tool input object
140
+ * @returns true if this is a Task tool call spawning an agent
141
+ */
142
+ export function isAgentSpawningCall(toolName: string, toolInput: any): boolean {
143
+ return toolName === 'Task' && toolInput?.subagent_type !== undefined;
144
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * PAI Path Resolution - Single Source of Truth
3
+ *
4
+ * This module provides consistent path resolution across all PAI hooks.
5
+ * It handles PAI_DIR detection whether set explicitly or defaulting to ~/.claude
6
+ *
7
+ * ALSO loads .env file from PAI_DIR so all hooks get environment variables
8
+ * without relying on Claude Code's settings.json injection.
9
+ *
10
+ * Usage in hooks:
11
+ * import { PAI_DIR, HOOKS_DIR, SKILLS_DIR } from './lib/pai-paths';
12
+ */
13
+
14
+ import { homedir } from 'os';
15
+ import { resolve, join } from 'path';
16
+ import { existsSync, readFileSync } from 'fs';
17
+
18
+ /**
19
+ * Load .env file and inject into process.env
20
+ * Must run BEFORE PAI_DIR resolution so .env can set PAI_DIR if needed
21
+ */
22
+ function loadEnvFile(): void {
23
+ // Check common locations for .env
24
+ const possiblePaths = [
25
+ resolve(process.env.PAI_DIR || '', '.env'),
26
+ resolve(homedir(), '.claude', '.env'),
27
+ ];
28
+
29
+ for (const envPath of possiblePaths) {
30
+ if (existsSync(envPath)) {
31
+ try {
32
+ const content = readFileSync(envPath, 'utf-8');
33
+ for (const line of content.split('\n')) {
34
+ const trimmed = line.trim();
35
+ // Skip comments and empty lines
36
+ if (!trimmed || trimmed.startsWith('#')) continue;
37
+
38
+ const eqIndex = trimmed.indexOf('=');
39
+ if (eqIndex > 0) {
40
+ const key = trimmed.substring(0, eqIndex).trim();
41
+ let value = trimmed.substring(eqIndex + 1).trim();
42
+
43
+ // Remove surrounding quotes if present
44
+ if ((value.startsWith('"') && value.endsWith('"')) ||
45
+ (value.startsWith("'") && value.endsWith("'"))) {
46
+ value = value.slice(1, -1);
47
+ }
48
+
49
+ // Expand $HOME and ~ in values
50
+ value = value.replace(/\$HOME/g, homedir());
51
+ value = value.replace(/^~(?=\/|$)/, homedir());
52
+
53
+ // Only set if not already defined (env vars take precedence)
54
+ if (process.env[key] === undefined) {
55
+ process.env[key] = value;
56
+ }
57
+ }
58
+ }
59
+ // Found and loaded, don't check other paths
60
+ break;
61
+ } catch {
62
+ // Silently continue if .env can't be read
63
+ }
64
+ }
65
+ }
66
+ }
67
+
68
+ // Load .env FIRST, before any other initialization
69
+ loadEnvFile();
70
+
71
+ /**
72
+ * Smart PAI_DIR detection with fallback
73
+ * Priority:
74
+ * 1. PAI_DIR environment variable (if set)
75
+ * 2. ~/.claude (standard location)
76
+ */
77
+ export const PAI_DIR = process.env.PAI_DIR
78
+ ? resolve(process.env.PAI_DIR)
79
+ : resolve(homedir(), '.claude');
80
+
81
+ /**
82
+ * Common PAI directories
83
+ */
84
+ export const HOOKS_DIR = join(PAI_DIR, 'Hooks');
85
+ export const SKILLS_DIR = join(PAI_DIR, 'Skills');
86
+ export const AGENTS_DIR = join(PAI_DIR, 'Agents');
87
+ export const HISTORY_DIR = join(PAI_DIR, 'History');
88
+ export const COMMANDS_DIR = join(PAI_DIR, 'Commands');
89
+
90
+ /**
91
+ * Validate PAI directory structure on first import
92
+ * This fails fast with a clear error if PAI is misconfigured
93
+ */
94
+ function validatePAIStructure(): void {
95
+ if (!existsSync(PAI_DIR)) {
96
+ console.error(`PAI_DIR does not exist: ${PAI_DIR}`);
97
+ console.error(` Expected ~/.claude or set PAI_DIR environment variable`);
98
+ process.exit(1);
99
+ }
100
+
101
+ if (!existsSync(HOOKS_DIR)) {
102
+ console.error(`PAI hooks directory not found: ${HOOKS_DIR}`);
103
+ console.error(` Your PAI_DIR may be misconfigured`);
104
+ console.error(` Current PAI_DIR: ${PAI_DIR}`);
105
+ process.exit(1);
106
+ }
107
+ }
108
+
109
+ // Run validation on module import
110
+ // This ensures any hook that imports this module will fail fast if paths are wrong
111
+ validatePAIStructure();
112
+
113
+ /**
114
+ * Helper to get history file path with date-based organization
115
+ */
116
+ export function getHistoryFilePath(subdir: string, filename: string): string {
117
+ const now = new Date();
118
+ const tz = process.env.TIME_ZONE || Intl.DateTimeFormat().resolvedOptions().timeZone;
119
+ const localDate = new Date(now.toLocaleString('en-US', { timeZone: tz }));
120
+ const year = localDate.getFullYear();
121
+ const month = String(localDate.getMonth() + 1).padStart(2, '0');
122
+
123
+ return join(HISTORY_DIR, subdir, `${year}-${month}`, filename);
124
+ }