agentrace 0.0.10 → 0.0.11

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/README.md CHANGED
@@ -35,11 +35,15 @@ That's it! When you use Claude Code, sessions will be automatically sent to Agen
35
35
  | -------------------------------------------- | -------------------------------------- |
36
36
  | `agentrace init --url <url>` | Initial setup + hooks installation |
37
37
  | `agentrace init --url <url> --proxy <proxy>` | Setup with proxy |
38
+ | `agentrace init --url <url> --local` | Setup for current project only |
38
39
  | `agentrace login` | Open the web dashboard |
39
40
  | `agentrace send` | Send transcript diff (used by hooks) |
40
41
  | `agentrace on` | Enable hooks |
42
+ | `agentrace on --local` | Enable hooks for current project |
41
43
  | `agentrace off` | Disable hooks |
44
+ | `agentrace off --local` | Disable hooks for current project |
42
45
  | `agentrace uninstall` | Remove hooks and configuration |
46
+ | `agentrace uninstall --local` | Remove project-local settings only |
43
47
 
44
48
  ## Command Details
45
49
 
@@ -48,7 +52,14 @@ That's it! When you use Claude Code, sessions will be automatically sent to Agen
48
52
  Sets up the server connection and installs Claude Code hooks.
49
53
 
50
54
  ```bash
55
+ # Global setup (all projects)
51
56
  npx agentrace init --url http://localhost:9080
57
+
58
+ # Project-local setup (current project only)
59
+ npx agentrace init --url http://localhost:9080 --local
60
+
61
+ # Project-local with separate config file
62
+ npx agentrace init --url http://localhost:9080 --local --separate-local-config
52
63
  ```
53
64
 
54
65
  **Process flow:**
@@ -57,6 +68,15 @@ npx agentrace init --url http://localhost:9080
57
68
  2. After registration, API key is automatically retrieved
58
69
  3. Claude Code hooks are configured
59
70
 
71
+ **Options:**
72
+
73
+ | Option | Description |
74
+ | ------ | ----------- |
75
+ | `--url <url>` | Server URL (required) |
76
+ | `--proxy <url>` | HTTP/HTTPS proxy URL |
77
+ | `--local` | Install hooks/MCP for current project only |
78
+ | `--separate-local-config` | Store config in project directory (requires --local) |
79
+
60
80
  ### login
61
81
 
62
82
  Issues a login URL for the web dashboard and opens it in browser.
@@ -70,11 +90,15 @@ npx agentrace login
70
90
  Toggle hooks enabled/disabled. Configuration is preserved.
71
91
 
72
92
  ```bash
73
- # Temporarily stop sending
93
+ # Temporarily stop sending (global)
74
94
  npx agentrace off
75
95
 
76
- # Resume sending
96
+ # Resume sending (global)
77
97
  npx agentrace on
98
+
99
+ # For project-local settings
100
+ npx agentrace off --local
101
+ npx agentrace on --local
78
102
  ```
79
103
 
80
104
  ### uninstall
@@ -82,7 +106,11 @@ npx agentrace on
82
106
  Completely removes hooks and configuration files.
83
107
 
84
108
  ```bash
109
+ # Remove global settings
85
110
  npx agentrace uninstall
111
+
112
+ # Remove project-local settings only
113
+ npx agentrace uninstall --local
86
114
  ```
87
115
 
88
116
  ### send
@@ -95,9 +123,28 @@ Configuration is stored in the following locations:
95
123
 
96
124
  | File | Location |
97
125
  | -------------------- | --------------------------------- |
98
- | AgenTrace config | `~/.config/agentrace/config.json` |
99
- | Cursor data | `~/.config/agentrace/cursors/` |
126
+ | AgenTrace config | `~/.agentrace/config.json` |
127
+ | Cursor data | `~/.agentrace/cursors/` |
100
128
  | Claude Code hooks | `~/.claude/settings.json` |
129
+ | MCP servers | `~/.claude.json` |
130
+
131
+ ### Project-Local Configuration
132
+
133
+ With the `--local` option, hooks and MCP are configured per-project:
134
+
135
+ | File | Location |
136
+ | -------------------- | ---------------------------------------------- |
137
+ | Claude Code hooks | `{project}/.claude/settings.json` |
138
+ | MCP servers | `~/.claude.json` (projects.{path}.mcpServers) |
139
+ | Config (optional) | `{project}/.agentrace/config.json` |
140
+
141
+ To also store the config locally, use `--separate-local-config`:
142
+
143
+ ```bash
144
+ npx agentrace init --url http://localhost:9080 --local --separate-local-config
145
+ ```
146
+
147
+ **Note:** Add `.agentrace/` to your `.gitignore` when using `--separate-local-config` to avoid committing API keys.
101
148
 
102
149
  ## How It Works
103
150
 
@@ -2,5 +2,7 @@ export interface InitOptions {
2
2
  url?: string;
3
3
  proxy?: string;
4
4
  dev?: boolean;
5
+ local?: boolean;
6
+ separateLocalConfig?: boolean;
5
7
  }
6
8
  export declare function initCommand(options?: InitOptions): Promise<void>;
@@ -1,7 +1,7 @@
1
1
  import * as path from "node:path";
2
2
  import { fileURLToPath } from "node:url";
3
3
  import { ProxyAgent } from "undici";
4
- import { saveConfig, getConfigPath } from "../config/manager.js";
4
+ import { saveConfig, getConfigPath, saveLocalConfig, getLocalConfigPath } from "../config/manager.js";
5
5
  import { installHooks, installMcpServer, installPreToolUseHook } from "../hooks/installer.js";
6
6
  import { startCallbackServer, getRandomPort, generateToken, } from "../utils/callback-server.js";
7
7
  import { openBrowser, buildSetupUrl } from "../utils/browser.js";
@@ -44,6 +44,11 @@ export async function initCommand(options = {}) {
44
44
  if (options.dev) {
45
45
  console.log("[Dev Mode] Using local CLI for hooks\n");
46
46
  }
47
+ if (options.local) {
48
+ console.log("[Local Mode] Installing hooks/MCP for this project only\n");
49
+ }
50
+ // Project directory for local scope
51
+ const projectDir = options.local ? process.cwd() : undefined;
47
52
  // Generate token and start callback server
48
53
  const token = generateToken();
49
54
  const port = getRandomPort();
@@ -73,12 +78,22 @@ export async function initCommand(options = {}) {
73
78
  const result = await callbackPromise;
74
79
  // Save config (remove trailing slash from URL)
75
80
  const serverUrlStr = serverUrl.toString().replace(/\/+$/, '');
76
- saveConfig({
81
+ const configData = {
77
82
  server_url: serverUrlStr,
78
83
  api_key: result.apiKey,
79
84
  ...(options.proxy && { proxy_url: options.proxy }),
80
- });
81
- console.log(`✓ Config saved to ${getConfigPath()}`);
85
+ };
86
+ if (options.local && options.separateLocalConfig && projectDir) {
87
+ // Save config locally in project directory
88
+ saveLocalConfig(projectDir, configData);
89
+ console.log(`✓ Config saved to ${getLocalConfigPath(projectDir)}`);
90
+ console.log(`⚠ Remember to add '.agentrace/' to your .gitignore`);
91
+ }
92
+ else {
93
+ // Save config globally
94
+ saveConfig(configData);
95
+ console.log(`✓ Config saved to ${getConfigPath()}`);
96
+ }
82
97
  if (options.proxy) {
83
98
  console.log(` Proxy: ${options.proxy}`);
84
99
  }
@@ -92,7 +107,11 @@ export async function initCommand(options = {}) {
92
107
  console.log(` Hook command: ${hookCommand}`);
93
108
  }
94
109
  // Install hooks
95
- const hookResult = installHooks({ command: hookCommand });
110
+ const hookResult = installHooks({
111
+ command: hookCommand,
112
+ local: options.local,
113
+ projectDir,
114
+ });
96
115
  if (hookResult.success) {
97
116
  console.log(`✓ ${hookResult.message}`);
98
117
  }
@@ -108,7 +127,12 @@ export async function initCommand(options = {}) {
108
127
  mcpCommand = "npx";
109
128
  mcpArgs = ["tsx", indexPath, "mcp-server"];
110
129
  }
111
- const mcpResult = installMcpServer({ command: mcpCommand, args: mcpArgs });
130
+ const mcpResult = installMcpServer({
131
+ command: mcpCommand,
132
+ args: mcpArgs,
133
+ local: options.local,
134
+ projectDir,
135
+ });
112
136
  if (mcpResult.success) {
113
137
  console.log(`✓ ${mcpResult.message}`);
114
138
  }
@@ -116,7 +140,10 @@ export async function initCommand(options = {}) {
116
140
  console.error(`✗ ${mcpResult.message}`);
117
141
  }
118
142
  // Install PreToolUse hook for session_id injection
119
- const preToolUseResult = installPreToolUseHook();
143
+ const preToolUseResult = installPreToolUseHook({
144
+ local: options.local,
145
+ projectDir,
146
+ });
120
147
  if (preToolUseResult.success) {
121
148
  console.log(`✓ ${preToolUseResult.message}`);
122
149
  }
@@ -126,7 +126,9 @@ IMPORTANT GUIDELINES:
126
126
  let client = null;
127
127
  function getClient() {
128
128
  if (!client) {
129
- client = new PlanDocumentClient();
129
+ // Use CLAUDE_PROJECT_DIR to find local config if available
130
+ const projectDir = process.env.CLAUDE_PROJECT_DIR;
131
+ client = new PlanDocumentClient(projectDir);
130
132
  }
131
133
  return client;
132
134
  }
@@ -1 +1,4 @@
1
- export declare function offCommand(): Promise<void>;
1
+ export interface OffOptions {
2
+ local?: boolean;
3
+ }
4
+ export declare function offCommand(options?: OffOptions): Promise<void>;
@@ -1,29 +1,42 @@
1
1
  import { uninstallHooks, uninstallMcpServer, uninstallPreToolUseHook } from "../hooks/installer.js";
2
- import { loadConfig } from "../config/manager.js";
3
- export async function offCommand() {
4
- // Check if config exists
5
- const config = loadConfig();
2
+ import { loadConfig, loadConfigWithFallback } from "../config/manager.js";
3
+ export async function offCommand(options = {}) {
4
+ const projectDir = options.local ? process.cwd() : undefined;
5
+ // Check if config exists (local config takes precedence if --local is specified)
6
+ const config = options.local ? loadConfigWithFallback(projectDir) : loadConfig();
6
7
  if (!config) {
7
8
  console.log("AgenTrace is not configured. Run 'npx agentrace init' first.");
8
9
  return;
9
10
  }
10
- const result = uninstallHooks();
11
+ if (options.local) {
12
+ console.log("[Local Mode] Disabling hooks/MCP for this project only\n");
13
+ }
14
+ const result = uninstallHooks({
15
+ local: options.local,
16
+ projectDir,
17
+ });
11
18
  if (result.success) {
12
19
  console.log(`✓ Hooks disabled. Your credentials are still saved.`);
13
- console.log(` Run 'npx agentrace on' to re-enable.`);
20
+ console.log(` Run 'npx agentrace on${options.local ? " --local" : ""}' to re-enable.`);
14
21
  }
15
22
  else {
16
23
  console.error(`✗ ${result.message}`);
17
24
  }
18
25
  // Remove PreToolUse hook
19
- const preToolUseResult = uninstallPreToolUseHook();
26
+ const preToolUseResult = uninstallPreToolUseHook({
27
+ local: options.local,
28
+ projectDir,
29
+ });
20
30
  if (preToolUseResult.success) {
21
31
  console.log(`✓ ${preToolUseResult.message}`);
22
32
  }
23
33
  else {
24
34
  console.error(`✗ ${preToolUseResult.message}`);
25
35
  }
26
- const mcpResult = uninstallMcpServer();
36
+ const mcpResult = uninstallMcpServer({
37
+ local: options.local,
38
+ projectDir,
39
+ });
27
40
  if (mcpResult.success) {
28
41
  console.log(`✓ ${mcpResult.message}`);
29
42
  }
@@ -1,4 +1,5 @@
1
1
  export interface OnOptions {
2
2
  dev?: boolean;
3
+ local?: boolean;
3
4
  }
4
5
  export declare function onCommand(options?: OnOptions): Promise<void>;
@@ -1,16 +1,20 @@
1
1
  import * as path from "node:path";
2
2
  import { fileURLToPath } from "node:url";
3
3
  import { installHooks, installMcpServer, installPreToolUseHook } from "../hooks/installer.js";
4
- import { loadConfig } from "../config/manager.js";
4
+ import { loadConfig, loadConfigWithFallback } from "../config/manager.js";
5
5
  const __filename = fileURLToPath(import.meta.url);
6
6
  const __dirname = path.dirname(__filename);
7
7
  export async function onCommand(options = {}) {
8
- // Check if config exists
9
- const config = loadConfig();
8
+ const projectDir = options.local ? process.cwd() : undefined;
9
+ // Check if config exists (local config takes precedence if --local is specified)
10
+ const config = options.local ? loadConfigWithFallback(projectDir) : loadConfig();
10
11
  if (!config) {
11
12
  console.log("AgenTrace is not configured. Run 'npx agentrace init' first.");
12
13
  return;
13
14
  }
15
+ if (options.local) {
16
+ console.log("[Local Mode] Enabling hooks/MCP for this project only\n");
17
+ }
14
18
  // Determine hook command
15
19
  let hookCommand;
16
20
  if (options.dev) {
@@ -19,7 +23,11 @@ export async function onCommand(options = {}) {
19
23
  const indexPath = path.join(cliRoot, "src/index.ts");
20
24
  hookCommand = `npx tsx ${indexPath} send`;
21
25
  }
22
- const result = installHooks({ command: hookCommand });
26
+ const result = installHooks({
27
+ command: hookCommand,
28
+ local: options.local,
29
+ projectDir,
30
+ });
23
31
  if (result.success) {
24
32
  console.log(`✓ Hooks enabled. Session data will be sent to ${config.server_url}`);
25
33
  }
@@ -35,7 +43,12 @@ export async function onCommand(options = {}) {
35
43
  mcpCommand = "npx";
36
44
  mcpArgs = ["tsx", indexPath, "mcp-server"];
37
45
  }
38
- const mcpResult = installMcpServer({ command: mcpCommand, args: mcpArgs });
46
+ const mcpResult = installMcpServer({
47
+ command: mcpCommand,
48
+ args: mcpArgs,
49
+ local: options.local,
50
+ projectDir,
51
+ });
39
52
  if (mcpResult.success) {
40
53
  console.log(`✓ ${mcpResult.message}`);
41
54
  }
@@ -43,7 +56,10 @@ export async function onCommand(options = {}) {
43
56
  console.error(`✗ ${mcpResult.message}`);
44
57
  }
45
58
  // Install PreToolUse hook for session_id injection
46
- const preToolUseResult = installPreToolUseHook();
59
+ const preToolUseResult = installPreToolUseHook({
60
+ local: options.local,
61
+ projectDir,
62
+ });
47
63
  if (preToolUseResult.success) {
48
64
  console.log(`✓ ${preToolUseResult.message}`);
49
65
  }
@@ -1,5 +1,5 @@
1
1
  import { execSync } from "child_process";
2
- import { loadConfig } from "../config/manager.js";
2
+ import { loadConfig, loadConfigWithFallback } from "../config/manager.js";
3
3
  import { getNewLines, saveCursor, hasCursor } from "../config/cursor.js";
4
4
  import { sendIngest } from "../utils/http.js";
5
5
  import { findSessionFile, extractCwdFromTranscript, } from "../utils/session-finder.js";
@@ -41,8 +41,8 @@ async function sendTranscript(params) {
41
41
  console.error(message);
42
42
  process.exit(isHook ? 0 : 1);
43
43
  };
44
- // Check if config exists
45
- const config = loadConfig();
44
+ // Check if config exists (local config takes precedence over global)
45
+ const config = loadConfigWithFallback(cwd);
46
46
  if (!config) {
47
47
  exitWithError("[agentrace] Warning: Config not found. Run 'npx agentrace init' first.");
48
48
  return;
@@ -1 +1,4 @@
1
- export declare function uninstallCommand(): Promise<void>;
1
+ export interface UninstallOptions {
2
+ local?: boolean;
3
+ }
4
+ export declare function uninstallCommand(options?: UninstallOptions): Promise<void>;
@@ -1,9 +1,18 @@
1
- import { deleteConfig } from "../config/manager.js";
1
+ import { deleteConfig, deleteLocalConfig } from "../config/manager.js";
2
2
  import { uninstallHooks, uninstallMcpServer, uninstallPreToolUseHook } from "../hooks/installer.js";
3
- export async function uninstallCommand() {
4
- console.log("Uninstalling AgenTrace...\n");
3
+ export async function uninstallCommand(options = {}) {
4
+ const projectDir = options.local ? process.cwd() : undefined;
5
+ if (options.local) {
6
+ console.log("Uninstalling AgenTrace (local settings only)...\n");
7
+ }
8
+ else {
9
+ console.log("Uninstalling AgenTrace...\n");
10
+ }
5
11
  // Remove hooks
6
- const hookResult = uninstallHooks();
12
+ const hookResult = uninstallHooks({
13
+ local: options.local,
14
+ projectDir,
15
+ });
7
16
  if (hookResult.success) {
8
17
  console.log(`✓ ${hookResult.message}`);
9
18
  }
@@ -11,7 +20,12 @@ export async function uninstallCommand() {
11
20
  console.error(`✗ ${hookResult.message}`);
12
21
  }
13
22
  // Remove PreToolUse hook
14
- const preToolUseResult = uninstallPreToolUseHook();
23
+ const preToolUseResult = uninstallPreToolUseHook({
24
+ local: options.local,
25
+ projectDir,
26
+ // Don't remove the hook script when uninstalling local settings
27
+ removeScript: !options.local,
28
+ });
15
29
  if (preToolUseResult.success) {
16
30
  console.log(`✓ ${preToolUseResult.message}`);
17
31
  }
@@ -19,7 +33,10 @@ export async function uninstallCommand() {
19
33
  console.error(`✗ ${preToolUseResult.message}`);
20
34
  }
21
35
  // Remove MCP server
22
- const mcpResult = uninstallMcpServer();
36
+ const mcpResult = uninstallMcpServer({
37
+ local: options.local,
38
+ projectDir,
39
+ });
23
40
  if (mcpResult.success) {
24
41
  console.log(`✓ ${mcpResult.message}`);
25
42
  }
@@ -27,12 +44,25 @@ export async function uninstallCommand() {
27
44
  console.error(`✗ ${mcpResult.message}`);
28
45
  }
29
46
  // Remove config
30
- const configRemoved = deleteConfig();
31
- if (configRemoved) {
32
- console.log("✓ Config removed");
47
+ if (options.local && projectDir) {
48
+ // Remove local config directory
49
+ const configRemoved = deleteLocalConfig(projectDir);
50
+ if (configRemoved) {
51
+ console.log("✓ Local config removed (.agentrace/)");
52
+ }
53
+ else {
54
+ console.log("✓ No local config to remove");
55
+ }
33
56
  }
34
57
  else {
35
- console.log("✓ No config to remove");
58
+ // Remove global config
59
+ const configRemoved = deleteConfig();
60
+ if (configRemoved) {
61
+ console.log("✓ Config removed");
62
+ }
63
+ else {
64
+ console.log("✓ No config to remove");
65
+ }
36
66
  }
37
67
  console.log("\nUninstall complete!");
38
68
  }
@@ -7,3 +7,12 @@ export declare function getConfigPath(): string;
7
7
  export declare function loadConfig(): AgentraceConfig | null;
8
8
  export declare function saveConfig(config: AgentraceConfig): void;
9
9
  export declare function deleteConfig(): boolean;
10
+ export declare function getLocalConfigDir(projectDir: string): string;
11
+ export declare function getLocalConfigPath(projectDir: string): string;
12
+ export declare function loadLocalConfig(projectDir: string): AgentraceConfig | null;
13
+ export declare function saveLocalConfig(projectDir: string, config: AgentraceConfig): void;
14
+ export declare function deleteLocalConfig(projectDir: string): boolean;
15
+ /**
16
+ * Load config with fallback: local config > global config
17
+ */
18
+ export declare function loadConfigWithFallback(projectDir?: string): AgentraceConfig | null;
@@ -36,3 +36,55 @@ export function deleteConfig() {
36
36
  return false;
37
37
  }
38
38
  }
39
+ // --- Local (project-level) config functions ---
40
+ export function getLocalConfigDir(projectDir) {
41
+ return path.join(projectDir, ".agentrace");
42
+ }
43
+ export function getLocalConfigPath(projectDir) {
44
+ return path.join(getLocalConfigDir(projectDir), "config.json");
45
+ }
46
+ export function loadLocalConfig(projectDir) {
47
+ const configFile = getLocalConfigPath(projectDir);
48
+ try {
49
+ if (!fs.existsSync(configFile)) {
50
+ return null;
51
+ }
52
+ const content = fs.readFileSync(configFile, "utf-8");
53
+ return JSON.parse(content);
54
+ }
55
+ catch {
56
+ return null;
57
+ }
58
+ }
59
+ export function saveLocalConfig(projectDir, config) {
60
+ const localConfigDir = getLocalConfigDir(projectDir);
61
+ const localConfigFile = getLocalConfigPath(projectDir);
62
+ if (!fs.existsSync(localConfigDir)) {
63
+ fs.mkdirSync(localConfigDir, { recursive: true });
64
+ }
65
+ fs.writeFileSync(localConfigFile, JSON.stringify(config, null, 2));
66
+ }
67
+ export function deleteLocalConfig(projectDir) {
68
+ const localConfigDir = getLocalConfigDir(projectDir);
69
+ try {
70
+ if (fs.existsSync(localConfigDir)) {
71
+ fs.rmSync(localConfigDir, { recursive: true });
72
+ return true;
73
+ }
74
+ return false;
75
+ }
76
+ catch {
77
+ return false;
78
+ }
79
+ }
80
+ /**
81
+ * Load config with fallback: local config > global config
82
+ */
83
+ export function loadConfigWithFallback(projectDir) {
84
+ if (projectDir) {
85
+ const localConfig = loadLocalConfig(projectDir);
86
+ if (localConfig)
87
+ return localConfig;
88
+ }
89
+ return loadConfig();
90
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,86 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import * as os from "node:os";
5
+ import { getConfigPath, loadLocalConfig, saveLocalConfig, deleteLocalConfig, getLocalConfigPath, loadConfigWithFallback, } from "./manager.js";
6
+ describe("config/manager", () => {
7
+ const testConfig = {
8
+ server_url: "http://localhost:8080",
9
+ api_key: "agtr_test_key",
10
+ };
11
+ describe("global config", () => {
12
+ it("getConfigPath returns expected path", () => {
13
+ expect(getConfigPath()).toBe(path.join(os.homedir(), ".agentrace", "config.json"));
14
+ });
15
+ });
16
+ describe("local config", () => {
17
+ let tempProjectDir;
18
+ beforeEach(() => {
19
+ tempProjectDir = fs.mkdtempSync(path.join(os.tmpdir(), "agentrace-project-"));
20
+ });
21
+ afterEach(() => {
22
+ if (fs.existsSync(tempProjectDir)) {
23
+ fs.rmSync(tempProjectDir, { recursive: true });
24
+ }
25
+ });
26
+ it("getLocalConfigPath returns correct path", () => {
27
+ const configPath = getLocalConfigPath(tempProjectDir);
28
+ expect(configPath).toBe(path.join(tempProjectDir, ".agentrace", "config.json"));
29
+ });
30
+ it("saveLocalConfig creates config file", () => {
31
+ saveLocalConfig(tempProjectDir, testConfig);
32
+ const configPath = getLocalConfigPath(tempProjectDir);
33
+ expect(fs.existsSync(configPath)).toBe(true);
34
+ const content = JSON.parse(fs.readFileSync(configPath, "utf-8"));
35
+ expect(content).toEqual(testConfig);
36
+ });
37
+ it("loadLocalConfig returns config when exists", () => {
38
+ saveLocalConfig(tempProjectDir, testConfig);
39
+ const loaded = loadLocalConfig(tempProjectDir);
40
+ expect(loaded).toEqual(testConfig);
41
+ });
42
+ it("loadLocalConfig returns null when config does not exist", () => {
43
+ const loaded = loadLocalConfig(tempProjectDir);
44
+ expect(loaded).toBeNull();
45
+ });
46
+ it("deleteLocalConfig removes config directory", () => {
47
+ saveLocalConfig(tempProjectDir, testConfig);
48
+ const configDir = path.join(tempProjectDir, ".agentrace");
49
+ expect(fs.existsSync(configDir)).toBe(true);
50
+ const result = deleteLocalConfig(tempProjectDir);
51
+ expect(result).toBe(true);
52
+ expect(fs.existsSync(configDir)).toBe(false);
53
+ });
54
+ it("deleteLocalConfig returns false when no config exists", () => {
55
+ const result = deleteLocalConfig(tempProjectDir);
56
+ expect(result).toBe(false);
57
+ });
58
+ });
59
+ describe("loadConfigWithFallback", () => {
60
+ let tempProjectDir;
61
+ const localConfig = {
62
+ server_url: "http://local:8080",
63
+ api_key: "test_local_key",
64
+ };
65
+ beforeEach(() => {
66
+ tempProjectDir = fs.mkdtempSync(path.join(os.tmpdir(), "agentrace-project-"));
67
+ });
68
+ afterEach(() => {
69
+ if (fs.existsSync(tempProjectDir)) {
70
+ fs.rmSync(tempProjectDir, { recursive: true });
71
+ }
72
+ });
73
+ it("returns local config when it exists", () => {
74
+ saveLocalConfig(tempProjectDir, localConfig);
75
+ const loaded = loadConfigWithFallback(tempProjectDir);
76
+ expect(loaded).toEqual(localConfig);
77
+ });
78
+ it("prefers local config over global config", () => {
79
+ // Save local config
80
+ saveLocalConfig(tempProjectDir, localConfig);
81
+ // loadConfigWithFallback should return local config
82
+ const loaded = loadConfigWithFallback(tempProjectDir);
83
+ expect(loaded).toEqual(localConfig);
84
+ });
85
+ });
86
+ });
@@ -1,34 +1,67 @@
1
1
  export interface InstallHooksOptions {
2
2
  command?: string;
3
+ local?: boolean;
4
+ projectDir?: string;
3
5
  }
4
6
  export declare function installHooks(options?: InstallHooksOptions): {
5
7
  success: boolean;
6
8
  message: string;
7
9
  };
8
- export declare function uninstallHooks(): {
10
+ export interface UninstallHooksOptions {
11
+ local?: boolean;
12
+ projectDir?: string;
13
+ }
14
+ export declare function uninstallHooks(options?: UninstallHooksOptions): {
9
15
  success: boolean;
10
16
  message: string;
11
17
  };
12
- export declare function checkHooksInstalled(): boolean;
18
+ export interface CheckHooksOptions {
19
+ local?: boolean;
20
+ projectDir?: string;
21
+ }
22
+ export declare function checkHooksInstalled(options?: CheckHooksOptions): boolean;
13
23
  export interface InstallMcpServerOptions {
14
24
  command?: string;
15
25
  args?: string[];
26
+ local?: boolean;
27
+ projectDir?: string;
16
28
  }
17
29
  export declare function installMcpServer(options?: InstallMcpServerOptions): {
18
30
  success: boolean;
19
31
  message: string;
20
32
  };
21
- export declare function uninstallMcpServer(): {
33
+ export interface UninstallMcpServerOptions {
34
+ local?: boolean;
35
+ projectDir?: string;
36
+ }
37
+ export declare function uninstallMcpServer(options?: UninstallMcpServerOptions): {
22
38
  success: boolean;
23
39
  message: string;
24
40
  };
25
- export declare function checkMcpServerInstalled(): boolean;
26
- export declare function installPreToolUseHook(): {
41
+ export interface CheckMcpServerOptions {
42
+ local?: boolean;
43
+ projectDir?: string;
44
+ }
45
+ export declare function checkMcpServerInstalled(options?: CheckMcpServerOptions): boolean;
46
+ export interface InstallPreToolUseHookOptions {
47
+ local?: boolean;
48
+ projectDir?: string;
49
+ }
50
+ export declare function installPreToolUseHook(options?: InstallPreToolUseHookOptions): {
27
51
  success: boolean;
28
52
  message: string;
29
53
  };
30
- export declare function uninstallPreToolUseHook(): {
54
+ export interface UninstallPreToolUseHookOptions {
55
+ local?: boolean;
56
+ projectDir?: string;
57
+ removeScript?: boolean;
58
+ }
59
+ export declare function uninstallPreToolUseHook(options?: UninstallPreToolUseHookOptions): {
31
60
  success: boolean;
32
61
  message: string;
33
62
  };
34
- export declare function checkPreToolUseHookInstalled(): boolean;
63
+ export interface CheckPreToolUseHookOptions {
64
+ local?: boolean;
65
+ projectDir?: string;
66
+ }
67
+ export declare function checkPreToolUseHookInstalled(options?: CheckPreToolUseHookOptions): boolean;
@@ -1,12 +1,19 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import * as os from "node:os";
4
- const CLAUDE_SETTINGS_PATH = path.join(os.homedir(), ".claude", "settings.json");
4
+ const GLOBAL_CLAUDE_SETTINGS_PATH = path.join(os.homedir(), ".claude", "settings.json");
5
5
  // MCP servers are configured in ~/.claude.json, NOT in settings.json
6
6
  const CLAUDE_CONFIG_PATH = path.join(os.homedir(), ".claude.json");
7
7
  // Agentrace hooks directory
8
8
  const AGENTRACE_HOOKS_DIR = path.join(os.homedir(), ".agentrace", "hooks");
9
9
  const SESSION_ID_HOOK_PATH = path.join(AGENTRACE_HOOKS_DIR, "inject-session-id.js");
10
+ // Helper to get settings path based on local/global scope
11
+ function getSettingsPath(options) {
12
+ if (options.local && options.projectDir) {
13
+ return path.join(options.projectDir, ".claude", "settings.json");
14
+ }
15
+ return GLOBAL_CLAUDE_SETTINGS_PATH;
16
+ }
10
17
  const DEFAULT_COMMAND = "npx agentrace send";
11
18
  function createAgentraceHook(command) {
12
19
  return {
@@ -21,11 +28,12 @@ function isAgentraceHook(hook) {
21
28
  export function installHooks(options = {}) {
22
29
  const command = options.command || DEFAULT_COMMAND;
23
30
  const agentraceHook = createAgentraceHook(command);
31
+ const settingsPath = getSettingsPath(options);
24
32
  try {
25
33
  let settings = {};
26
34
  // Load existing settings if file exists
27
- if (fs.existsSync(CLAUDE_SETTINGS_PATH)) {
28
- const content = fs.readFileSync(CLAUDE_SETTINGS_PATH, "utf-8");
35
+ if (fs.existsSync(settingsPath)) {
36
+ const content = fs.readFileSync(settingsPath, "utf-8");
29
37
  settings = JSON.parse(content);
30
38
  }
31
39
  // Initialize hooks structure if not present
@@ -76,25 +84,26 @@ export function installHooks(options = {}) {
76
84
  });
77
85
  }
78
86
  // Ensure directory exists
79
- const dir = path.dirname(CLAUDE_SETTINGS_PATH);
87
+ const dir = path.dirname(settingsPath);
80
88
  if (!fs.existsSync(dir)) {
81
89
  fs.mkdirSync(dir, { recursive: true });
82
90
  }
83
91
  // Write settings
84
- fs.writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2));
85
- return { success: true, message: `Hooks added to ${CLAUDE_SETTINGS_PATH}` };
92
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
93
+ return { success: true, message: `Hooks added to ${settingsPath}` };
86
94
  }
87
95
  catch (error) {
88
96
  const message = error instanceof Error ? error.message : String(error);
89
97
  return { success: false, message: `Failed to install hooks: ${message}` };
90
98
  }
91
99
  }
92
- export function uninstallHooks() {
100
+ export function uninstallHooks(options = {}) {
101
+ const settingsPath = getSettingsPath(options);
93
102
  try {
94
- if (!fs.existsSync(CLAUDE_SETTINGS_PATH)) {
103
+ if (!fs.existsSync(settingsPath)) {
95
104
  return { success: true, message: "No settings file found" };
96
105
  }
97
- const content = fs.readFileSync(CLAUDE_SETTINGS_PATH, "utf-8");
106
+ const content = fs.readFileSync(settingsPath, "utf-8");
98
107
  const settings = JSON.parse(content);
99
108
  if (!settings.hooks) {
100
109
  return { success: true, message: "No hooks configured" };
@@ -131,10 +140,10 @@ export function uninstallHooks() {
131
140
  if (Object.keys(settings.hooks).length === 0) {
132
141
  delete settings.hooks;
133
142
  }
134
- fs.writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2));
143
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
135
144
  return {
136
145
  success: true,
137
- message: `Removed hooks from ${CLAUDE_SETTINGS_PATH}`,
146
+ message: `Removed hooks from ${settingsPath}`,
138
147
  };
139
148
  }
140
149
  catch (error) {
@@ -142,12 +151,13 @@ export function uninstallHooks() {
142
151
  return { success: false, message: `Failed to uninstall hooks: ${message}` };
143
152
  }
144
153
  }
145
- export function checkHooksInstalled() {
154
+ export function checkHooksInstalled(options = {}) {
155
+ const settingsPath = getSettingsPath(options);
146
156
  try {
147
- if (!fs.existsSync(CLAUDE_SETTINGS_PATH)) {
157
+ if (!fs.existsSync(settingsPath)) {
148
158
  return false;
149
159
  }
150
- const content = fs.readFileSync(CLAUDE_SETTINGS_PATH, "utf-8");
160
+ const content = fs.readFileSync(settingsPath, "utf-8");
151
161
  const settings = JSON.parse(content);
152
162
  const hasStopHook = settings.hooks?.Stop?.some((matcher) => matcher.hooks?.some(isAgentraceHook));
153
163
  const hasUserPromptSubmitHook = settings.hooks?.UserPromptSubmit?.some((matcher) => matcher.hooks?.some(isAgentraceHook));
@@ -172,63 +182,107 @@ export function installMcpServer(options = {}) {
172
182
  const content = fs.readFileSync(CLAUDE_CONFIG_PATH, "utf-8");
173
183
  config = JSON.parse(content);
174
184
  }
175
- // Initialize mcpServers structure if not present
176
- if (!config.mcpServers) {
177
- config.mcpServers = {};
178
- }
179
- // Check if already installed
180
- if (config.mcpServers[MCP_SERVER_NAME]) {
181
- // Update existing config
185
+ if (options.local && options.projectDir) {
186
+ // Local scope: add to projects.{projectDir}.mcpServers
187
+ if (!config.projects) {
188
+ config.projects = {};
189
+ }
190
+ if (!config.projects[options.projectDir]) {
191
+ config.projects[options.projectDir] = {};
192
+ }
193
+ if (!config.projects[options.projectDir].mcpServers) {
194
+ config.projects[options.projectDir].mcpServers = {};
195
+ }
196
+ const projectMcpServers = config.projects[options.projectDir].mcpServers;
197
+ const alreadyExists = !!projectMcpServers[MCP_SERVER_NAME];
198
+ projectMcpServers[MCP_SERVER_NAME] = { command, args };
199
+ fs.writeFileSync(CLAUDE_CONFIG_PATH, JSON.stringify(config, null, 2));
200
+ return {
201
+ success: true,
202
+ message: alreadyExists
203
+ ? "MCP server config updated (local)"
204
+ : `MCP server added to ${CLAUDE_CONFIG_PATH} (local scope for ${options.projectDir})`,
205
+ };
206
+ }
207
+ else {
208
+ // User scope: add to mcpServers (global)
209
+ if (!config.mcpServers) {
210
+ config.mcpServers = {};
211
+ }
212
+ const alreadyExists = !!config.mcpServers[MCP_SERVER_NAME];
182
213
  config.mcpServers[MCP_SERVER_NAME] = { command, args };
183
214
  fs.writeFileSync(CLAUDE_CONFIG_PATH, JSON.stringify(config, null, 2));
184
- return { success: true, message: "MCP server config updated" };
215
+ return {
216
+ success: true,
217
+ message: alreadyExists
218
+ ? "MCP server config updated"
219
+ : `MCP server added to ${CLAUDE_CONFIG_PATH}`,
220
+ };
185
221
  }
186
- // Add MCP server config
187
- config.mcpServers[MCP_SERVER_NAME] = { command, args };
188
- // Write config
189
- fs.writeFileSync(CLAUDE_CONFIG_PATH, JSON.stringify(config, null, 2));
190
- return { success: true, message: `MCP server added to ${CLAUDE_CONFIG_PATH}` };
191
222
  }
192
223
  catch (error) {
193
224
  const message = error instanceof Error ? error.message : String(error);
194
225
  return { success: false, message: `Failed to install MCP server: ${message}` };
195
226
  }
196
227
  }
197
- export function uninstallMcpServer() {
228
+ export function uninstallMcpServer(options = {}) {
198
229
  try {
199
230
  if (!fs.existsSync(CLAUDE_CONFIG_PATH)) {
200
231
  return { success: true, message: "No config file found" };
201
232
  }
202
233
  const content = fs.readFileSync(CLAUDE_CONFIG_PATH, "utf-8");
203
234
  const config = JSON.parse(content);
204
- if (!config.mcpServers || !config.mcpServers[MCP_SERVER_NAME]) {
205
- return { success: true, message: "MCP server not configured" };
206
- }
207
- // Remove agentrace MCP server
208
- delete config.mcpServers[MCP_SERVER_NAME];
209
- // Clean up empty mcpServers object
210
- if (Object.keys(config.mcpServers).length === 0) {
211
- delete config.mcpServers;
235
+ if (options.local && options.projectDir) {
236
+ // Local scope: remove from projects.{projectDir}.mcpServers
237
+ if (!config.projects?.[options.projectDir]?.mcpServers?.[MCP_SERVER_NAME]) {
238
+ return { success: true, message: "MCP server not configured (local)" };
239
+ }
240
+ delete config.projects[options.projectDir].mcpServers[MCP_SERVER_NAME];
241
+ // Clean up empty mcpServers object
242
+ if (Object.keys(config.projects[options.projectDir].mcpServers).length === 0) {
243
+ delete config.projects[options.projectDir].mcpServers;
244
+ }
245
+ fs.writeFileSync(CLAUDE_CONFIG_PATH, JSON.stringify(config, null, 2));
246
+ return {
247
+ success: true,
248
+ message: `Removed MCP server from ${CLAUDE_CONFIG_PATH} (local scope)`,
249
+ };
250
+ }
251
+ else {
252
+ // User scope: remove from mcpServers (global)
253
+ if (!config.mcpServers || !config.mcpServers[MCP_SERVER_NAME]) {
254
+ return { success: true, message: "MCP server not configured" };
255
+ }
256
+ delete config.mcpServers[MCP_SERVER_NAME];
257
+ // Clean up empty mcpServers object
258
+ if (Object.keys(config.mcpServers).length === 0) {
259
+ delete config.mcpServers;
260
+ }
261
+ fs.writeFileSync(CLAUDE_CONFIG_PATH, JSON.stringify(config, null, 2));
262
+ return {
263
+ success: true,
264
+ message: `Removed MCP server from ${CLAUDE_CONFIG_PATH}`,
265
+ };
212
266
  }
213
- fs.writeFileSync(CLAUDE_CONFIG_PATH, JSON.stringify(config, null, 2));
214
- return {
215
- success: true,
216
- message: `Removed MCP server from ${CLAUDE_CONFIG_PATH}`,
217
- };
218
267
  }
219
268
  catch (error) {
220
269
  const message = error instanceof Error ? error.message : String(error);
221
270
  return { success: false, message: `Failed to uninstall MCP server: ${message}` };
222
271
  }
223
272
  }
224
- export function checkMcpServerInstalled() {
273
+ export function checkMcpServerInstalled(options = {}) {
225
274
  try {
226
275
  if (!fs.existsSync(CLAUDE_CONFIG_PATH)) {
227
276
  return false;
228
277
  }
229
278
  const content = fs.readFileSync(CLAUDE_CONFIG_PATH, "utf-8");
230
279
  const config = JSON.parse(content);
231
- return !!config.mcpServers?.[MCP_SERVER_NAME];
280
+ if (options.local && options.projectDir) {
281
+ return !!config.projects?.[options.projectDir]?.mcpServers?.[MCP_SERVER_NAME];
282
+ }
283
+ else {
284
+ return !!config.mcpServers?.[MCP_SERVER_NAME];
285
+ }
232
286
  }
233
287
  catch {
234
288
  return false;
@@ -276,7 +330,8 @@ function isAgentracePreToolUseHook(matcher) {
276
330
  return matcher.matcher === AGENTRACE_MCP_TOOLS_MATCHER &&
277
331
  matcher.hooks?.some(h => h.command?.includes("inject-session-id"));
278
332
  }
279
- export function installPreToolUseHook() {
333
+ export function installPreToolUseHook(options = {}) {
334
+ const settingsPath = getSettingsPath(options);
280
335
  try {
281
336
  // Create hooks directory if not exists
282
337
  if (!fs.existsSync(AGENTRACE_HOOKS_DIR)) {
@@ -286,8 +341,8 @@ export function installPreToolUseHook() {
286
341
  fs.writeFileSync(SESSION_ID_HOOK_PATH, SESSION_ID_HOOK_SCRIPT, { mode: 0o755 });
287
342
  // Load existing settings
288
343
  let settings = {};
289
- if (fs.existsSync(CLAUDE_SETTINGS_PATH)) {
290
- const content = fs.readFileSync(CLAUDE_SETTINGS_PATH, "utf-8");
344
+ if (fs.existsSync(settingsPath)) {
345
+ const content = fs.readFileSync(settingsPath, "utf-8");
291
346
  settings = JSON.parse(content);
292
347
  }
293
348
  // Initialize hooks structure if not present
@@ -313,12 +368,12 @@ export function installPreToolUseHook() {
313
368
  ],
314
369
  });
315
370
  // Ensure directory exists
316
- const dir = path.dirname(CLAUDE_SETTINGS_PATH);
371
+ const dir = path.dirname(settingsPath);
317
372
  if (!fs.existsSync(dir)) {
318
373
  fs.mkdirSync(dir, { recursive: true });
319
374
  }
320
375
  // Write settings
321
- fs.writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2));
376
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
322
377
  return { success: true, message: `PreToolUse hook installed to ${SESSION_ID_HOOK_PATH}` };
323
378
  }
324
379
  catch (error) {
@@ -326,17 +381,18 @@ export function installPreToolUseHook() {
326
381
  return { success: false, message: `Failed to install PreToolUse hook: ${message}` };
327
382
  }
328
383
  }
329
- export function uninstallPreToolUseHook() {
384
+ export function uninstallPreToolUseHook(options = {}) {
385
+ const settingsPath = getSettingsPath(options);
330
386
  try {
331
- // Remove hook script
332
- if (fs.existsSync(SESSION_ID_HOOK_PATH)) {
387
+ // Remove hook script only when uninstalling global hooks (not local)
388
+ if (!options.local && options.removeScript !== false && fs.existsSync(SESSION_ID_HOOK_PATH)) {
333
389
  fs.unlinkSync(SESSION_ID_HOOK_PATH);
334
390
  }
335
391
  // Remove from settings
336
- if (!fs.existsSync(CLAUDE_SETTINGS_PATH)) {
392
+ if (!fs.existsSync(settingsPath)) {
337
393
  return { success: true, message: "No settings file found" };
338
394
  }
339
- const content = fs.readFileSync(CLAUDE_SETTINGS_PATH, "utf-8");
395
+ const content = fs.readFileSync(settingsPath, "utf-8");
340
396
  const settings = JSON.parse(content);
341
397
  if (!settings.hooks?.PreToolUse) {
342
398
  return { success: true, message: "No PreToolUse hooks configured" };
@@ -350,7 +406,7 @@ export function uninstallPreToolUseHook() {
350
406
  if (Object.keys(settings.hooks).length === 0) {
351
407
  delete settings.hooks;
352
408
  }
353
- fs.writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2));
409
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
354
410
  return { success: true, message: "PreToolUse hook removed" };
355
411
  }
356
412
  catch (error) {
@@ -358,12 +414,13 @@ export function uninstallPreToolUseHook() {
358
414
  return { success: false, message: `Failed to uninstall PreToolUse hook: ${message}` };
359
415
  }
360
416
  }
361
- export function checkPreToolUseHookInstalled() {
417
+ export function checkPreToolUseHookInstalled(options = {}) {
418
+ const settingsPath = getSettingsPath(options);
362
419
  try {
363
- if (!fs.existsSync(CLAUDE_SETTINGS_PATH) || !fs.existsSync(SESSION_ID_HOOK_PATH)) {
420
+ if (!fs.existsSync(settingsPath) || !fs.existsSync(SESSION_ID_HOOK_PATH)) {
364
421
  return false;
365
422
  }
366
- const content = fs.readFileSync(CLAUDE_SETTINGS_PATH, "utf-8");
423
+ const content = fs.readFileSync(settingsPath, "utf-8");
367
424
  const settings = JSON.parse(content);
368
425
  return settings.hooks?.PreToolUse?.some(isAgentracePreToolUseHook) ?? false;
369
426
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,166 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import * as os from "node:os";
5
+ import { installHooks, uninstallHooks, checkHooksInstalled, installMcpServer, uninstallMcpServer, checkMcpServerInstalled, } from "./installer.js";
6
+ describe("hooks/installer", () => {
7
+ describe("local hooks", () => {
8
+ let tempProjectDir;
9
+ beforeEach(() => {
10
+ tempProjectDir = fs.mkdtempSync(path.join(os.tmpdir(), "agentrace-project-"));
11
+ });
12
+ afterEach(() => {
13
+ if (fs.existsSync(tempProjectDir)) {
14
+ fs.rmSync(tempProjectDir, { recursive: true });
15
+ }
16
+ });
17
+ it("installHooks creates local settings.json", () => {
18
+ const result = installHooks({
19
+ local: true,
20
+ projectDir: tempProjectDir,
21
+ });
22
+ expect(result.success).toBe(true);
23
+ const settingsPath = path.join(tempProjectDir, ".claude", "settings.json");
24
+ expect(fs.existsSync(settingsPath)).toBe(true);
25
+ const settings = JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
26
+ expect(settings.hooks).toBeDefined();
27
+ expect(settings.hooks.Stop).toBeDefined();
28
+ expect(settings.hooks.UserPromptSubmit).toBeDefined();
29
+ expect(settings.hooks.SubagentStop).toBeDefined();
30
+ expect(settings.hooks.PostToolUse).toBeDefined();
31
+ });
32
+ it("installHooks with custom command", () => {
33
+ const customCommand = "npx custom-cli send";
34
+ const result = installHooks({
35
+ command: customCommand,
36
+ local: true,
37
+ projectDir: tempProjectDir,
38
+ });
39
+ expect(result.success).toBe(true);
40
+ const settingsPath = path.join(tempProjectDir, ".claude", "settings.json");
41
+ const settings = JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
42
+ expect(settings.hooks.Stop[0].hooks[0].command).toBe(customCommand);
43
+ });
44
+ it("checkHooksInstalled returns true for local hooks", () => {
45
+ installHooks({
46
+ local: true,
47
+ projectDir: tempProjectDir,
48
+ });
49
+ const installed = checkHooksInstalled({
50
+ local: true,
51
+ projectDir: tempProjectDir,
52
+ });
53
+ expect(installed).toBe(true);
54
+ });
55
+ it("checkHooksInstalled returns false when no local hooks", () => {
56
+ const installed = checkHooksInstalled({
57
+ local: true,
58
+ projectDir: tempProjectDir,
59
+ });
60
+ expect(installed).toBe(false);
61
+ });
62
+ it("uninstallHooks removes local hooks", () => {
63
+ installHooks({
64
+ local: true,
65
+ projectDir: tempProjectDir,
66
+ });
67
+ const result = uninstallHooks({
68
+ local: true,
69
+ projectDir: tempProjectDir,
70
+ });
71
+ expect(result.success).toBe(true);
72
+ const installed = checkHooksInstalled({
73
+ local: true,
74
+ projectDir: tempProjectDir,
75
+ });
76
+ expect(installed).toBe(false);
77
+ });
78
+ });
79
+ describe("local MCP server", () => {
80
+ let tempProjectDir;
81
+ let claudeJsonPath;
82
+ let originalClaudeJson = null;
83
+ beforeEach(() => {
84
+ tempProjectDir = fs.mkdtempSync(path.join(os.tmpdir(), "agentrace-project-"));
85
+ claudeJsonPath = path.join(os.homedir(), ".claude.json");
86
+ // Backup original ~/.claude.json if exists
87
+ if (fs.existsSync(claudeJsonPath)) {
88
+ originalClaudeJson = fs.readFileSync(claudeJsonPath, "utf-8");
89
+ }
90
+ });
91
+ afterEach(() => {
92
+ if (fs.existsSync(tempProjectDir)) {
93
+ fs.rmSync(tempProjectDir, { recursive: true });
94
+ }
95
+ // Restore original ~/.claude.json
96
+ if (originalClaudeJson !== null) {
97
+ fs.writeFileSync(claudeJsonPath, originalClaudeJson);
98
+ }
99
+ else if (fs.existsSync(claudeJsonPath)) {
100
+ // If original didn't exist but now exists, we need to clean up
101
+ // But we should be careful not to delete user's actual config
102
+ // So we just leave it as is
103
+ }
104
+ });
105
+ it("installMcpServer adds to projects for local scope", () => {
106
+ const result = installMcpServer({
107
+ local: true,
108
+ projectDir: tempProjectDir,
109
+ });
110
+ expect(result.success).toBe(true);
111
+ expect(result.message).toContain("local scope");
112
+ const claudeJson = JSON.parse(fs.readFileSync(claudeJsonPath, "utf-8"));
113
+ expect(claudeJson.projects).toBeDefined();
114
+ expect(claudeJson.projects[tempProjectDir]).toBeDefined();
115
+ expect(claudeJson.projects[tempProjectDir].mcpServers).toBeDefined();
116
+ expect(claudeJson.projects[tempProjectDir].mcpServers.agentrace).toBeDefined();
117
+ });
118
+ it("checkMcpServerInstalled returns true for local MCP", () => {
119
+ installMcpServer({
120
+ local: true,
121
+ projectDir: tempProjectDir,
122
+ });
123
+ const installed = checkMcpServerInstalled({
124
+ local: true,
125
+ projectDir: tempProjectDir,
126
+ });
127
+ expect(installed).toBe(true);
128
+ });
129
+ it("checkMcpServerInstalled returns false when no local MCP", () => {
130
+ const installed = checkMcpServerInstalled({
131
+ local: true,
132
+ projectDir: tempProjectDir,
133
+ });
134
+ expect(installed).toBe(false);
135
+ });
136
+ it("uninstallMcpServer removes local MCP", () => {
137
+ installMcpServer({
138
+ local: true,
139
+ projectDir: tempProjectDir,
140
+ });
141
+ const result = uninstallMcpServer({
142
+ local: true,
143
+ projectDir: tempProjectDir,
144
+ });
145
+ expect(result.success).toBe(true);
146
+ const installed = checkMcpServerInstalled({
147
+ local: true,
148
+ projectDir: tempProjectDir,
149
+ });
150
+ expect(installed).toBe(false);
151
+ });
152
+ it("installMcpServer with custom command and args", () => {
153
+ const result = installMcpServer({
154
+ command: "node",
155
+ args: ["custom-server.js"],
156
+ local: true,
157
+ projectDir: tempProjectDir,
158
+ });
159
+ expect(result.success).toBe(true);
160
+ const claudeJson = JSON.parse(fs.readFileSync(claudeJsonPath, "utf-8"));
161
+ const mcpConfig = claudeJson.projects[tempProjectDir].mcpServers.agentrace;
162
+ expect(mcpConfig.command).toBe("node");
163
+ expect(mcpConfig.args).toEqual(["custom-server.js"]);
164
+ });
165
+ });
166
+ });
package/dist/index.js CHANGED
@@ -15,8 +15,16 @@ program
15
15
  .requiredOption("--url <url>", "Server URL (required)")
16
16
  .option("--proxy <url>", "HTTP/HTTPS proxy URL")
17
17
  .option("--dev", "Use local CLI path for development")
18
+ .option("--local", "Install hooks/MCP for current project only (project-local scope)")
19
+ .option("--separate-local-config", "Store config in project directory (requires --local)")
18
20
  .action(async (options) => {
19
- await initCommand({ url: options.url, proxy: options.proxy, dev: options.dev });
21
+ await initCommand({
22
+ url: options.url,
23
+ proxy: options.proxy,
24
+ dev: options.dev,
25
+ local: options.local,
26
+ separateLocalConfig: options.separateLocalConfig,
27
+ });
20
28
  });
21
29
  program
22
30
  .command("login")
@@ -39,21 +47,24 @@ program
39
47
  program
40
48
  .command("uninstall")
41
49
  .description("Remove agentrace hooks and config")
42
- .action(async () => {
43
- await uninstallCommand();
50
+ .option("--local", "Remove only project-local hooks/MCP/config")
51
+ .action(async (options) => {
52
+ await uninstallCommand({ local: options.local });
44
53
  });
45
54
  program
46
55
  .command("on")
47
56
  .description("Enable agentrace hooks (credentials preserved)")
48
57
  .option("--dev", "Use local CLI path for development")
58
+ .option("--local", "Enable hooks/MCP for current project only")
49
59
  .action(async (options) => {
50
- await onCommand({ dev: options.dev });
60
+ await onCommand({ dev: options.dev, local: options.local });
51
61
  });
52
62
  program
53
63
  .command("off")
54
64
  .description("Disable agentrace hooks (credentials preserved)")
55
- .action(async () => {
56
- await offCommand();
65
+ .option("--local", "Disable hooks/MCP for current project only")
66
+ .action(async (options) => {
67
+ await offCommand({ local: options.local });
57
68
  });
58
69
  program
59
70
  .command("mcp-server")
@@ -56,7 +56,7 @@ export interface UpdatePlanRequest {
56
56
  export declare class PlanDocumentClient {
57
57
  private serverUrl;
58
58
  private apiKey;
59
- constructor();
59
+ constructor(projectDir?: string);
60
60
  private request;
61
61
  searchPlans(params?: SearchPlansParams): Promise<PlanDocument[]>;
62
62
  getPlan(id: string): Promise<PlanDocument>;
@@ -1,11 +1,11 @@
1
1
  import { fetch } from "undici";
2
- import { loadConfig } from "../config/manager.js";
2
+ import { loadConfigWithFallback } from "../config/manager.js";
3
3
  import { createDispatcher } from "../utils/proxy.js";
4
4
  export class PlanDocumentClient {
5
5
  serverUrl;
6
6
  apiKey;
7
- constructor() {
8
- const config = loadConfig();
7
+ constructor(projectDir) {
8
+ const config = loadConfigWithFallback(projectDir);
9
9
  if (!config) {
10
10
  throw new Error("AgenTrace is not configured. Run 'npx agentrace init' first.");
11
11
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentrace",
3
- "version": "0.0.10",
3
+ "version": "0.0.11",
4
4
  "description": "CLI for AgenTrace - Claude Code session tracker",
5
5
  "type": "module",
6
6
  "bin": {