ashlrcode 1.0.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +73 -16
- package/package.json +28 -9
- package/prompts/skills/commit.md +36 -0
- package/prompts/skills/coordinate.md +21 -0
- package/prompts/skills/daily-review.md +65 -0
- package/prompts/skills/debug.md +23 -0
- package/prompts/skills/deep-work.md +129 -0
- package/prompts/skills/explore.md +24 -0
- package/prompts/skills/init.md +39 -0
- package/prompts/skills/kairos.md +19 -0
- package/prompts/skills/plan.md +19 -0
- package/prompts/skills/polish.md +94 -0
- package/prompts/skills/pr.md +30 -0
- package/prompts/skills/refactor.md +26 -0
- package/prompts/skills/resume-branch.md +27 -0
- package/prompts/skills/review.md +27 -0
- package/prompts/skills/ship.md +32 -0
- package/prompts/skills/simplify.md +25 -0
- package/prompts/skills/test.md +19 -0
- package/prompts/skills/verify.md +17 -0
- package/prompts/skills/weekly-plan.md +63 -0
- package/prompts/system.md +451 -0
- package/src/agent/away-summary.ts +138 -0
- package/src/agent/context.ts +6 -0
- package/src/agent/coordinator.ts +494 -0
- package/src/agent/dream.ts +149 -11
- package/src/agent/error-handler.ts +51 -35
- package/src/agent/kairos.ts +52 -4
- package/src/agent/loop.ts +153 -13
- package/src/agent/mailbox.ts +151 -0
- package/src/agent/model-patches.ts +28 -3
- package/src/agent/product-agent.ts +463 -0
- package/src/agent/speculation.ts +21 -18
- package/src/agent/sub-agent.ts +11 -1
- package/src/agent/system-prompt.ts +19 -0
- package/src/agent/tool-executor.ts +83 -3
- package/src/agent/verification.ts +223 -0
- package/src/agent/worktree-manager.ts +50 -1
- package/src/cli.ts +228 -36
- package/src/config/features.ts +8 -8
- package/src/config/keychain.ts +105 -0
- package/src/config/permissions.ts +3 -2
- package/src/config/settings.ts +73 -5
- package/src/config/upgrade-notice.ts +15 -2
- package/src/mcp/client.ts +392 -2
- package/src/mcp/manager.ts +129 -13
- package/src/mcp/types.ts +4 -1
- package/src/migrate.ts +228 -0
- package/src/persistence/session.ts +209 -5
- package/src/providers/anthropic.ts +112 -98
- package/src/providers/cost-tracker.ts +71 -2
- package/src/providers/retry.ts +2 -4
- package/src/providers/types.ts +5 -1
- package/src/providers/xai.ts +1 -0
- package/src/repl.tsx +514 -127
- package/src/setup.ts +37 -1
- package/src/tools/coordinate.ts +88 -0
- package/src/tools/grep.ts +9 -11
- package/src/tools/lsp.ts +44 -32
- package/src/tools/registry.ts +75 -9
- package/src/tools/send-message.ts +89 -30
- package/src/tools/types.ts +2 -0
- package/src/tools/verify.ts +88 -0
- package/src/tools/web-browser.ts +8 -5
- package/src/tools/workflow.ts +34 -10
- package/src/ui/AnimatedSpinner.tsx +302 -0
- package/src/ui/App.tsx +16 -15
- package/src/ui/BuddyPanel.tsx +27 -34
- package/src/ui/SlashInput.tsx +99 -0
- package/src/ui/banner.ts +10 -0
- package/src/ui/buddy.ts +5 -4
- package/src/ui/effort.ts +5 -1
- package/src/ui/markdown.ts +269 -88
- package/src/ui/message-renderer.ts +183 -35
- package/src/ui/quips.json +41 -0
- package/src/ui/speech-bubble.ts +35 -19
- package/src/utils/ring-buffer.ts +101 -0
- package/src/voice/voice-mode.ts +13 -2
- package/src/__tests__/branded-types.test.ts +0 -47
- package/src/__tests__/context.test.ts +0 -163
- package/src/__tests__/cost-tracker.test.ts +0 -274
- package/src/__tests__/cron.test.ts +0 -197
- package/src/__tests__/dream.test.ts +0 -204
- package/src/__tests__/error-handler.test.ts +0 -192
- package/src/__tests__/features.test.ts +0 -69
- package/src/__tests__/file-history.test.ts +0 -177
- package/src/__tests__/hooks.test.ts +0 -145
- package/src/__tests__/keybindings.test.ts +0 -159
- package/src/__tests__/model-patches.test.ts +0 -82
- package/src/__tests__/permissions-rules.test.ts +0 -121
- package/src/__tests__/permissions.test.ts +0 -108
- package/src/__tests__/project-config.test.ts +0 -63
- package/src/__tests__/retry.test.ts +0 -321
- package/src/__tests__/router.test.ts +0 -158
- package/src/__tests__/session-compact.test.ts +0 -191
- package/src/__tests__/session.test.ts +0 -145
- package/src/__tests__/skill-registry.test.ts +0 -130
- package/src/__tests__/speculation.test.ts +0 -196
- package/src/__tests__/tasks-v2.test.ts +0 -267
- package/src/__tests__/telemetry.test.ts +0 -149
- package/src/__tests__/tool-executor.test.ts +0 -141
- package/src/__tests__/tool-registry.test.ts +0 -166
- package/src/__tests__/undercover.test.ts +0 -93
- package/src/__tests__/workflow.test.ts +0 -195
package/src/mcp/manager.ts
CHANGED
|
@@ -3,13 +3,21 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import chalk from "chalk";
|
|
6
|
-
import { MCPClient } from "./client.ts";
|
|
6
|
+
import { MCPClient, MCPSSEClient, MCPWebSocketClient, createMCPClient } from "./client.ts";
|
|
7
7
|
import type { MCPServerConfig, MCPToolInfo } from "./types.ts";
|
|
8
8
|
import { authorizeOAuth } from "./oauth.ts";
|
|
9
9
|
import type { OAuthConfig } from "./oauth.ts";
|
|
10
10
|
|
|
11
|
+
type MCPClientType = MCPClient | MCPSSEClient | MCPWebSocketClient;
|
|
12
|
+
|
|
13
|
+
export type MCPConnectionState = "connected" | "disconnected" | "reconnecting";
|
|
14
|
+
|
|
11
15
|
export class MCPManager {
|
|
12
|
-
private clients = new Map<string,
|
|
16
|
+
private clients = new Map<string, MCPClientType>();
|
|
17
|
+
private configs = new Map<string, MCPServerConfig>();
|
|
18
|
+
/** Original configs (pre-OAuth) for token refresh on reconnect */
|
|
19
|
+
private originalConfigs = new Map<string, MCPServerConfig>();
|
|
20
|
+
private connectionStates = new Map<string, MCPConnectionState>();
|
|
13
21
|
|
|
14
22
|
/**
|
|
15
23
|
* Connect to all configured MCP servers.
|
|
@@ -50,17 +58,22 @@ export class MCPManager {
|
|
|
50
58
|
}
|
|
51
59
|
}
|
|
52
60
|
|
|
53
|
-
|
|
61
|
+
this.originalConfigs.set(name, config);
|
|
62
|
+
this.configs.set(name, effectiveConfig);
|
|
63
|
+
const client = createMCPClient(name, effectiveConfig);
|
|
54
64
|
try {
|
|
55
65
|
await client.connect();
|
|
56
66
|
this.clients.set(name, client);
|
|
67
|
+
this.connectionStates.set(name, "connected");
|
|
57
68
|
console.log(
|
|
58
69
|
chalk.dim(
|
|
59
70
|
` MCP: ${name} connected (${client.tools.length} tools)`
|
|
60
71
|
)
|
|
61
72
|
);
|
|
62
|
-
} catch {
|
|
63
|
-
|
|
73
|
+
} catch (err) {
|
|
74
|
+
this.connectionStates.set(name, "disconnected");
|
|
75
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
76
|
+
console.log(chalk.yellow(` MCP: ${name} failed to connect — ${msg.slice(0, 100)}`));
|
|
64
77
|
}
|
|
65
78
|
})
|
|
66
79
|
);
|
|
@@ -81,6 +94,7 @@ export class MCPManager {
|
|
|
81
94
|
|
|
82
95
|
/**
|
|
83
96
|
* Call a tool on a specific server.
|
|
97
|
+
* On connection error, attempts one reconnect before failing.
|
|
84
98
|
*/
|
|
85
99
|
async callTool(
|
|
86
100
|
serverName: string,
|
|
@@ -92,18 +106,120 @@ export class MCPManager {
|
|
|
92
106
|
return `MCP server "${serverName}" not connected`;
|
|
93
107
|
}
|
|
94
108
|
|
|
95
|
-
|
|
109
|
+
try {
|
|
110
|
+
const result = await client.callTool(toolName, args);
|
|
111
|
+
|
|
112
|
+
// Extract text from result content blocks
|
|
113
|
+
const text = result.content
|
|
114
|
+
.map((c) => c.text ?? JSON.stringify(c))
|
|
115
|
+
.join("\n");
|
|
116
|
+
|
|
117
|
+
if (result.isError) {
|
|
118
|
+
return `MCP Error: ${text}`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return text;
|
|
122
|
+
} catch (err) {
|
|
123
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
124
|
+
const isConnectionError =
|
|
125
|
+
msg.includes("connection closed") ||
|
|
126
|
+
msg.includes("not connected") ||
|
|
127
|
+
msg.includes("disconnected") ||
|
|
128
|
+
msg.includes("stream ended") ||
|
|
129
|
+
msg.includes("WebSocket connection closed") ||
|
|
130
|
+
msg.includes("WebSocket not connected");
|
|
131
|
+
|
|
132
|
+
if (!isConnectionError) {
|
|
133
|
+
return `MCP Error: ${msg}`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Attempt one reconnect
|
|
137
|
+
console.log(chalk.dim(` MCP: ${serverName} connection lost, attempting reconnect...`));
|
|
138
|
+
const reconnected = await this.reconnect(serverName);
|
|
139
|
+
if (!reconnected) {
|
|
140
|
+
return `MCP server "${serverName}" disconnected and reconnect failed`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Retry the tool call after successful reconnect
|
|
144
|
+
try {
|
|
145
|
+
const retryClient = this.clients.get(serverName);
|
|
146
|
+
if (!retryClient) return `MCP server "${serverName}" not connected after reconnect`;
|
|
147
|
+
const result = await retryClient.callTool(toolName, args);
|
|
148
|
+
const text = result.content
|
|
149
|
+
.map((c) => c.text ?? JSON.stringify(c))
|
|
150
|
+
.join("\n");
|
|
151
|
+
if (result.isError) return `MCP Error: ${text}`;
|
|
152
|
+
return text;
|
|
153
|
+
} catch (retryErr) {
|
|
154
|
+
const retryMsg = retryErr instanceof Error ? retryErr.message : String(retryErr);
|
|
155
|
+
return `MCP Error (after reconnect): ${retryMsg}`;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Reconnect to a specific MCP server.
|
|
162
|
+
* Returns true if reconnection succeeded, false otherwise.
|
|
163
|
+
*/
|
|
164
|
+
async reconnect(serverName: string): Promise<boolean> {
|
|
165
|
+
const originalConfig = this.originalConfigs.get(serverName);
|
|
166
|
+
if (!originalConfig) {
|
|
167
|
+
console.log(chalk.yellow(` MCP: ${serverName} — no config found for reconnect`));
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
96
170
|
|
|
97
|
-
|
|
98
|
-
const text = result.content
|
|
99
|
-
.map((c) => c.text ?? JSON.stringify(c))
|
|
100
|
-
.join("\n");
|
|
171
|
+
this.connectionStates.set(serverName, "reconnecting");
|
|
101
172
|
|
|
102
|
-
if
|
|
103
|
-
|
|
173
|
+
// Disconnect existing client if any
|
|
174
|
+
const existing = this.clients.get(serverName);
|
|
175
|
+
if (existing) {
|
|
176
|
+
try { await existing.disconnect(); } catch { /* ignore */ }
|
|
177
|
+
this.clients.delete(serverName);
|
|
104
178
|
}
|
|
105
179
|
|
|
106
|
-
|
|
180
|
+
// Re-do OAuth if the original config had it (token may have expired)
|
|
181
|
+
let effectiveConfig = originalConfig;
|
|
182
|
+
if (originalConfig.oauth) {
|
|
183
|
+
try {
|
|
184
|
+
const oauthConfig: OAuthConfig = {
|
|
185
|
+
...originalConfig.oauth,
|
|
186
|
+
scopes: originalConfig.oauth.scopes ?? [],
|
|
187
|
+
};
|
|
188
|
+
const token = await authorizeOAuth(serverName, oauthConfig);
|
|
189
|
+
effectiveConfig = {
|
|
190
|
+
...originalConfig,
|
|
191
|
+
env: {
|
|
192
|
+
...originalConfig.env,
|
|
193
|
+
MCP_AUTH_TOKEN: token.accessToken,
|
|
194
|
+
MCP_AUTH_TYPE: token.tokenType,
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
} catch {
|
|
198
|
+
console.log(chalk.yellow(` MCP: ${serverName} OAuth refresh failed during reconnect`));
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
try {
|
|
203
|
+
this.configs.set(serverName, effectiveConfig);
|
|
204
|
+
const client = createMCPClient(serverName, effectiveConfig);
|
|
205
|
+
await client.connect();
|
|
206
|
+
this.clients.set(serverName, client);
|
|
207
|
+
this.connectionStates.set(serverName, "connected");
|
|
208
|
+
console.log(chalk.dim(` MCP: ${serverName} reconnected (${client.tools.length} tools)`));
|
|
209
|
+
return true;
|
|
210
|
+
} catch (err) {
|
|
211
|
+
this.connectionStates.set(serverName, "disconnected");
|
|
212
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
213
|
+
console.log(chalk.yellow(` MCP: ${serverName} reconnect failed — ${msg.slice(0, 100)}`));
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Get the connection state for a server.
|
|
220
|
+
*/
|
|
221
|
+
getConnectionState(serverName: string): MCPConnectionState {
|
|
222
|
+
return this.connectionStates.get(serverName) ?? "disconnected";
|
|
107
223
|
}
|
|
108
224
|
|
|
109
225
|
/**
|
package/src/mcp/types.ts
CHANGED
|
@@ -3,9 +3,12 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
export interface MCPServerConfig {
|
|
6
|
-
|
|
6
|
+
/** stdio transport: command to spawn */
|
|
7
|
+
command?: string;
|
|
7
8
|
args?: string[];
|
|
8
9
|
env?: Record<string, string>;
|
|
10
|
+
/** SSE transport: URL of MCP server (e.g. http://localhost:3000) */
|
|
11
|
+
url?: string;
|
|
9
12
|
oauth?: {
|
|
10
13
|
authorizationUrl: string;
|
|
11
14
|
tokenUrl: string;
|
package/src/migrate.ts
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Migrate from Claude Code — one-time config copy.
|
|
3
|
+
*
|
|
4
|
+
* Reads ~/.claude/settings.json and copies:
|
|
5
|
+
* - MCP server configurations
|
|
6
|
+
* - Permission rules (converted to AshlrCode format)
|
|
7
|
+
* - Custom slash commands / skills
|
|
8
|
+
*
|
|
9
|
+
* Does NOT copy: sessions (different format), API keys (different providers),
|
|
10
|
+
* or settings that don't apply (model names, IDE extensions, etc.)
|
|
11
|
+
*
|
|
12
|
+
* Usage: ac --migrate
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { existsSync } from "fs";
|
|
16
|
+
import { readFile, writeFile, mkdir, readdir, copyFile } from "fs/promises";
|
|
17
|
+
import { join } from "path";
|
|
18
|
+
import { homedir } from "os";
|
|
19
|
+
import chalk from "chalk";
|
|
20
|
+
import { getConfigDir, loadSettings, saveSettings, type Settings } from "./config/settings.ts";
|
|
21
|
+
|
|
22
|
+
const CLAUDE_DIR = join(homedir(), ".claude");
|
|
23
|
+
const CLAUDE_SETTINGS = join(CLAUDE_DIR, "settings.json");
|
|
24
|
+
const CLAUDE_COMMANDS_DIR = join(CLAUDE_DIR, "commands");
|
|
25
|
+
|
|
26
|
+
interface ClaudeSettings {
|
|
27
|
+
mcpServers?: Record<string, {
|
|
28
|
+
command?: string;
|
|
29
|
+
args?: string[];
|
|
30
|
+
env?: Record<string, string>;
|
|
31
|
+
url?: string;
|
|
32
|
+
// Claude Code may have additional fields we don't need
|
|
33
|
+
[key: string]: unknown;
|
|
34
|
+
}>;
|
|
35
|
+
permissions?: {
|
|
36
|
+
allow?: string[];
|
|
37
|
+
deny?: string[];
|
|
38
|
+
};
|
|
39
|
+
hooks?: {
|
|
40
|
+
preToolUse?: Array<{
|
|
41
|
+
matcher?: string;
|
|
42
|
+
command?: string;
|
|
43
|
+
[key: string]: unknown;
|
|
44
|
+
}>;
|
|
45
|
+
};
|
|
46
|
+
[key: string]: unknown;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function runMigration(): Promise<void> {
|
|
50
|
+
console.log(chalk.cyan("\n 🔄 AshlrCode Migration from Claude Code\n"));
|
|
51
|
+
|
|
52
|
+
// Check Claude Code config exists
|
|
53
|
+
if (!existsSync(CLAUDE_DIR)) {
|
|
54
|
+
console.log(chalk.yellow(" ~/.claude/ directory not found."));
|
|
55
|
+
console.log(chalk.dim(" Make sure Claude Code is installed and has been run at least once.\n"));
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Load existing AshlrCode settings (or defaults)
|
|
60
|
+
const acSettings = await loadSettings();
|
|
61
|
+
let changesMade = false;
|
|
62
|
+
|
|
63
|
+
// ── Step 1: Migrate MCP Servers ──────────────────────────────
|
|
64
|
+
if (existsSync(CLAUDE_SETTINGS)) {
|
|
65
|
+
console.log(chalk.white(" Reading ~/.claude/settings.json..."));
|
|
66
|
+
try {
|
|
67
|
+
const raw = await readFile(CLAUDE_SETTINGS, "utf-8");
|
|
68
|
+
const claudeSettings = JSON.parse(raw) as ClaudeSettings;
|
|
69
|
+
|
|
70
|
+
// Also check project-level settings
|
|
71
|
+
const projectSettingsPath = join(CLAUDE_DIR, "settings.local.json");
|
|
72
|
+
let projectSettings: ClaudeSettings = {};
|
|
73
|
+
if (existsSync(projectSettingsPath)) {
|
|
74
|
+
try {
|
|
75
|
+
projectSettings = JSON.parse(await readFile(projectSettingsPath, "utf-8")) as ClaudeSettings;
|
|
76
|
+
} catch { /* ignore */ }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Merge MCP servers from both global and project settings
|
|
80
|
+
const allMcpServers = {
|
|
81
|
+
...claudeSettings.mcpServers,
|
|
82
|
+
...projectSettings.mcpServers,
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
if (Object.keys(allMcpServers).length > 0) {
|
|
86
|
+
acSettings.mcpServers = acSettings.mcpServers ?? {};
|
|
87
|
+
|
|
88
|
+
let migrated = 0;
|
|
89
|
+
let skipped = 0;
|
|
90
|
+
|
|
91
|
+
for (const [name, config] of Object.entries(allMcpServers)) {
|
|
92
|
+
if (acSettings.mcpServers[name]) {
|
|
93
|
+
console.log(chalk.dim(` ⊘ ${name} — already exists, skipping`));
|
|
94
|
+
skipped++;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Convert to AshlrCode MCPServerConfig format
|
|
99
|
+
// We only copy fields our config supports
|
|
100
|
+
const acConfig: Record<string, unknown> = {};
|
|
101
|
+
if (config.command) acConfig.command = config.command;
|
|
102
|
+
if (config.args) acConfig.args = config.args;
|
|
103
|
+
if (config.env) acConfig.env = config.env;
|
|
104
|
+
if (config.url) acConfig.url = config.url;
|
|
105
|
+
|
|
106
|
+
acSettings.mcpServers[name] = acConfig as any;
|
|
107
|
+
console.log(chalk.green(` ✓ ${name} — migrated (${config.command ? "stdio" : config.url ? "SSE" : "unknown transport"})`));
|
|
108
|
+
migrated++;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (migrated > 0) {
|
|
112
|
+
changesMade = true;
|
|
113
|
+
console.log(chalk.cyan(`\n MCP: ${migrated} servers migrated, ${skipped} skipped\n`));
|
|
114
|
+
} else {
|
|
115
|
+
console.log(chalk.dim(`\n MCP: All ${skipped} servers already exist\n`));
|
|
116
|
+
}
|
|
117
|
+
} else {
|
|
118
|
+
console.log(chalk.dim(" No MCP servers found in Claude Code config\n"));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ── Step 2: Migrate Permission Rules ──────────────────────
|
|
122
|
+
const claudePerms = claudeSettings.permissions;
|
|
123
|
+
if (claudePerms) {
|
|
124
|
+
acSettings.permissionRules = acSettings.permissionRules ?? [];
|
|
125
|
+
let permsMigrated = 0;
|
|
126
|
+
|
|
127
|
+
if (claudePerms.allow) {
|
|
128
|
+
for (const pattern of claudePerms.allow) {
|
|
129
|
+
// Claude Code allow rules are tool name patterns
|
|
130
|
+
const exists = acSettings.permissionRules.some(
|
|
131
|
+
(r) => r.tool === pattern && r.action === "allow"
|
|
132
|
+
);
|
|
133
|
+
if (!exists) {
|
|
134
|
+
acSettings.permissionRules.push({ tool: pattern, action: "allow" });
|
|
135
|
+
console.log(chalk.green(` ✓ Allow rule: ${pattern}`));
|
|
136
|
+
permsMigrated++;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (claudePerms.deny) {
|
|
142
|
+
for (const pattern of claudePerms.deny) {
|
|
143
|
+
const exists = acSettings.permissionRules.some(
|
|
144
|
+
(r) => r.tool === pattern && r.action === "deny"
|
|
145
|
+
);
|
|
146
|
+
if (!exists) {
|
|
147
|
+
acSettings.permissionRules.push({ tool: pattern, action: "deny" });
|
|
148
|
+
console.log(chalk.green(` ✓ Deny rule: ${pattern}`));
|
|
149
|
+
permsMigrated++;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (permsMigrated > 0) {
|
|
155
|
+
changesMade = true;
|
|
156
|
+
console.log(chalk.cyan(`\n Permissions: ${permsMigrated} rules migrated\n`));
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
} catch (err) {
|
|
160
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
161
|
+
console.log(chalk.red(` Failed to read Claude Code settings: ${msg}\n`));
|
|
162
|
+
}
|
|
163
|
+
} else {
|
|
164
|
+
console.log(chalk.dim(" No ~/.claude/settings.json found\n"));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ── Step 3: Migrate Custom Commands → Skills ──────────────
|
|
168
|
+
if (existsSync(CLAUDE_COMMANDS_DIR)) {
|
|
169
|
+
const skillsDir = join(getConfigDir(), "skills");
|
|
170
|
+
await mkdir(skillsDir, { recursive: true });
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
const files = await readdir(CLAUDE_COMMANDS_DIR);
|
|
174
|
+
const mdFiles = files.filter((f) => f.endsWith(".md"));
|
|
175
|
+
let skillsMigrated = 0;
|
|
176
|
+
|
|
177
|
+
for (const file of mdFiles) {
|
|
178
|
+
const destPath = join(skillsDir, file);
|
|
179
|
+
if (existsSync(destPath)) {
|
|
180
|
+
console.log(chalk.dim(` ⊘ ${file} — already exists, skipping`));
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const content = await readFile(join(CLAUDE_COMMANDS_DIR, file), "utf-8");
|
|
185
|
+
|
|
186
|
+
// Convert Claude Code command format to AshlrCode skill format
|
|
187
|
+
// Claude uses $ARGUMENTS, AshlrCode uses {{args}}
|
|
188
|
+
let converted = content.replace(/\$ARGUMENTS/g, "{{args}}");
|
|
189
|
+
converted = converted.replace(/\$arguments/g, "{{args}}");
|
|
190
|
+
|
|
191
|
+
// If the file has no frontmatter, add basic frontmatter
|
|
192
|
+
if (!converted.startsWith("---")) {
|
|
193
|
+
const name = file.replace(".md", "");
|
|
194
|
+
converted = `---\nname: ${name}\ndescription: Migrated from Claude Code\ntrigger: /${name}\n---\n\n${converted}`;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
await writeFile(destPath, converted, "utf-8");
|
|
198
|
+
console.log(chalk.green(` ✓ ${file} → skills/${file}`));
|
|
199
|
+
skillsMigrated++;
|
|
200
|
+
changesMade = true;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (skillsMigrated > 0) {
|
|
204
|
+
console.log(chalk.cyan(`\n Skills: ${skillsMigrated} commands migrated\n`));
|
|
205
|
+
} else if (mdFiles.length > 0) {
|
|
206
|
+
console.log(chalk.dim(`\n Skills: All commands already migrated\n`));
|
|
207
|
+
}
|
|
208
|
+
} catch (err) {
|
|
209
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
210
|
+
console.log(chalk.dim(` Could not read Claude Code commands: ${msg}\n`));
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ── Step 4: Save ──────────────────────────────────────────
|
|
215
|
+
if (changesMade) {
|
|
216
|
+
await saveSettings(acSettings);
|
|
217
|
+
console.log(chalk.green(" ✓ Settings saved to ~/.ashlrcode/settings.json"));
|
|
218
|
+
console.log(chalk.dim(" Restart AshlrCode for changes to take effect.\n"));
|
|
219
|
+
} else {
|
|
220
|
+
console.log(chalk.dim(" No changes needed — config is up to date.\n"));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ── Summary ───────────────────────────────────────────────
|
|
224
|
+
console.log(chalk.white(" Next steps:"));
|
|
225
|
+
console.log(chalk.dim(" 1. Set your API key: export XAI_API_KEY=your-key"));
|
|
226
|
+
console.log(chalk.dim(" 2. Run: ac"));
|
|
227
|
+
console.log(chalk.dim(" 3. Your MCP servers and skills will be available automatically\n"));
|
|
228
|
+
}
|
|
@@ -8,11 +8,12 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { existsSync } from "fs";
|
|
11
|
-
import { readFile,
|
|
11
|
+
import { readFile, appendFile, mkdir, readdir, unlink, stat } from "fs/promises";
|
|
12
12
|
import { join } from "path";
|
|
13
13
|
import { randomUUID } from "crypto";
|
|
14
14
|
import { getConfigDir } from "../config/settings.ts";
|
|
15
15
|
import type { Message } from "../providers/types.ts";
|
|
16
|
+
import type { SessionId } from "../types/branded.ts";
|
|
16
17
|
|
|
17
18
|
export interface SessionMetadata {
|
|
18
19
|
id: string;
|
|
@@ -39,6 +40,12 @@ export class Session {
|
|
|
39
40
|
readonly id: string;
|
|
40
41
|
private filePath: string;
|
|
41
42
|
private metadata: SessionMetadata;
|
|
43
|
+
/**
|
|
44
|
+
* Fire-and-forget write queue. Assistant messages are appended via this
|
|
45
|
+
* queue for low latency — writes are ordered but non-blocking. User messages
|
|
46
|
+
* bypass this queue and await directly (crash recovery requires durability).
|
|
47
|
+
*/
|
|
48
|
+
private writeQueue: Promise<void> = Promise.resolve();
|
|
42
49
|
|
|
43
50
|
constructor(id?: string) {
|
|
44
51
|
this.id = id ?? randomUUID().slice(0, 8);
|
|
@@ -65,14 +72,32 @@ export class Session {
|
|
|
65
72
|
});
|
|
66
73
|
}
|
|
67
74
|
|
|
75
|
+
/**
|
|
76
|
+
* Append a message to the session log.
|
|
77
|
+
* User messages are written with blocking I/O (crash recovery).
|
|
78
|
+
* Assistant messages use fire-and-forget with ordering guarantees.
|
|
79
|
+
*/
|
|
68
80
|
async appendMessage(message: Message): Promise<void> {
|
|
69
81
|
this.metadata.messageCount++;
|
|
70
82
|
this.metadata.updatedAt = new Date().toISOString();
|
|
71
|
-
|
|
83
|
+
const entry: SessionEntry = {
|
|
72
84
|
type: "message",
|
|
73
85
|
timestamp: new Date().toISOString(),
|
|
74
86
|
data: message,
|
|
75
|
-
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
if (message.role === "user") {
|
|
90
|
+
// Blocking write — if we crash, user input is preserved
|
|
91
|
+
await this.appendEntry(entry);
|
|
92
|
+
} else {
|
|
93
|
+
// Fire-and-forget with ordering — chain onto write queue
|
|
94
|
+
this.writeQueue = this.writeQueue.then(() =>
|
|
95
|
+
this.appendEntry(entry)
|
|
96
|
+
).catch((err) => {
|
|
97
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
98
|
+
console.error("[session] Fire-and-forget write failed:", msg);
|
|
99
|
+
});
|
|
100
|
+
}
|
|
76
101
|
}
|
|
77
102
|
|
|
78
103
|
async appendMessages(messages: Message[]): Promise<void> {
|
|
@@ -81,6 +106,11 @@ export class Session {
|
|
|
81
106
|
}
|
|
82
107
|
}
|
|
83
108
|
|
|
109
|
+
/** Wait for all queued writes to complete (call before exit). */
|
|
110
|
+
async flush(): Promise<void> {
|
|
111
|
+
await this.writeQueue;
|
|
112
|
+
}
|
|
113
|
+
|
|
84
114
|
async insertCompactBoundary(summary: string, messageCountBefore: number): Promise<void> {
|
|
85
115
|
await this.appendEntry({
|
|
86
116
|
type: "compact",
|
|
@@ -178,8 +208,13 @@ export class Session {
|
|
|
178
208
|
}
|
|
179
209
|
|
|
180
210
|
private async appendEntry(entry: SessionEntry): Promise<void> {
|
|
181
|
-
|
|
182
|
-
|
|
211
|
+
try {
|
|
212
|
+
await mkdir(getSessionsDir(), { recursive: true });
|
|
213
|
+
await appendFile(this.filePath, JSON.stringify(entry) + "\n", "utf-8");
|
|
214
|
+
} catch (error: unknown) {
|
|
215
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
216
|
+
console.error("[session] Failed to persist entry:", msg);
|
|
217
|
+
}
|
|
183
218
|
}
|
|
184
219
|
}
|
|
185
220
|
|
|
@@ -275,6 +310,9 @@ export async function forkSession(
|
|
|
275
310
|
export async function compactSession(id: string): Promise<{ messagesBefore: number; summary: string }> {
|
|
276
311
|
const session = new Session(id);
|
|
277
312
|
const allMessages = await session.loadAllMessages();
|
|
313
|
+
if (allMessages.length === 0) {
|
|
314
|
+
throw new Error(`Session ${id} not found or empty`);
|
|
315
|
+
}
|
|
278
316
|
const messagesBefore = allMessages.length;
|
|
279
317
|
|
|
280
318
|
// Generate summary from recent messages
|
|
@@ -287,3 +325,169 @@ export async function compactSession(id: string): Promise<{ messagesBefore: numb
|
|
|
287
325
|
await session.insertCompactBoundary(summary, messagesBefore);
|
|
288
326
|
return { messagesBefore, summary };
|
|
289
327
|
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Import a Claude Code JSONL session file into AshlrCode format.
|
|
331
|
+
*
|
|
332
|
+
* Claude Code sessions use JSONL with entries like:
|
|
333
|
+
* {"type":"human","message":{"role":"user","content":"..."}}
|
|
334
|
+
* {"type":"assistant","message":{"role":"assistant","content":[...]}}
|
|
335
|
+
*
|
|
336
|
+
* This is a best-effort parser — unparseable lines are skipped.
|
|
337
|
+
*/
|
|
338
|
+
export async function importClaudeCodeSession(
|
|
339
|
+
claudeSessionPath: string,
|
|
340
|
+
provider = "imported",
|
|
341
|
+
model = "claude-code"
|
|
342
|
+
): Promise<Session> {
|
|
343
|
+
if (!existsSync(claudeSessionPath)) {
|
|
344
|
+
throw new Error(`Session file not found: ${claudeSessionPath}`);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const content = await readFile(claudeSessionPath, "utf-8");
|
|
348
|
+
const lines = content.trim().split("\n").filter(Boolean);
|
|
349
|
+
const messages: Message[] = [];
|
|
350
|
+
|
|
351
|
+
for (const line of lines) {
|
|
352
|
+
try {
|
|
353
|
+
const entry = JSON.parse(line);
|
|
354
|
+
|
|
355
|
+
// Claude Code format: {type: "human"|"assistant", message: {role, content}}
|
|
356
|
+
if (entry.message && entry.message.role && entry.message.content !== undefined) {
|
|
357
|
+
const role = entry.message.role;
|
|
358
|
+
if (role === "user" || role === "assistant") {
|
|
359
|
+
// Normalize content — Claude Code may use content blocks or strings
|
|
360
|
+
let normalizedContent: string;
|
|
361
|
+
if (typeof entry.message.content === "string") {
|
|
362
|
+
normalizedContent = entry.message.content;
|
|
363
|
+
} else if (Array.isArray(entry.message.content)) {
|
|
364
|
+
// Extract text from content blocks
|
|
365
|
+
const textParts: string[] = [];
|
|
366
|
+
for (const block of entry.message.content) {
|
|
367
|
+
if (block.type === "text" && block.text) {
|
|
368
|
+
textParts.push(block.text);
|
|
369
|
+
} else if (typeof block === "string") {
|
|
370
|
+
textParts.push(block);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
normalizedContent = textParts.join("\n");
|
|
374
|
+
} else {
|
|
375
|
+
continue; // Skip entries with unrecognized content format
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (normalizedContent.trim()) {
|
|
379
|
+
messages.push({ role, content: normalizedContent });
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Alternative format: direct {role, content} entries (some Claude Code variants)
|
|
385
|
+
else if (entry.role && entry.content !== undefined && !entry.type) {
|
|
386
|
+
const role = entry.role;
|
|
387
|
+
if (role === "user" || role === "assistant") {
|
|
388
|
+
const normalizedContent = typeof entry.content === "string"
|
|
389
|
+
? entry.content
|
|
390
|
+
: Array.isArray(entry.content)
|
|
391
|
+
? entry.content
|
|
392
|
+
.filter((b: Record<string, unknown>) => b.type === "text" && b.text)
|
|
393
|
+
.map((b: Record<string, unknown>) => b.text as string)
|
|
394
|
+
.join("\n")
|
|
395
|
+
: "";
|
|
396
|
+
|
|
397
|
+
if (normalizedContent.trim()) {
|
|
398
|
+
messages.push({ role, content: normalizedContent });
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// AshlrCode's own format: {type: "message", data: {role, content}}
|
|
404
|
+
else if (entry.type === "message" && entry.data?.role) {
|
|
405
|
+
const role = entry.data.role;
|
|
406
|
+
if (role === "user" || role === "assistant") {
|
|
407
|
+
const normalizedContent = typeof entry.data.content === "string"
|
|
408
|
+
? entry.data.content
|
|
409
|
+
: "";
|
|
410
|
+
if (normalizedContent.trim()) {
|
|
411
|
+
messages.push({ role, content: normalizedContent });
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
} catch {
|
|
416
|
+
// Skip unparseable lines
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (messages.length === 0) {
|
|
421
|
+
throw new Error("No parseable messages found in session file.");
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const session = new Session();
|
|
425
|
+
await session.init(provider, model);
|
|
426
|
+
await session.appendMessages(messages);
|
|
427
|
+
await session.setTitle(`Imported from ${claudeSessionPath.split("/").pop() ?? "claude-code"}`);
|
|
428
|
+
|
|
429
|
+
return session;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Prune old session files. Keeps at most `maxCount` sessions,
|
|
434
|
+
* deleting the oldest by modification time. Optionally deletes
|
|
435
|
+
* sessions older than `maxAgeDays`.
|
|
436
|
+
*
|
|
437
|
+
* Returns the number of files deleted.
|
|
438
|
+
*/
|
|
439
|
+
export async function pruneOldSessions(
|
|
440
|
+
maxCount = 100,
|
|
441
|
+
maxAgeDays?: number
|
|
442
|
+
): Promise<number> {
|
|
443
|
+
const sessionsDir = getSessionsDir();
|
|
444
|
+
if (!existsSync(sessionsDir)) return 0;
|
|
445
|
+
|
|
446
|
+
const files = await readdir(sessionsDir);
|
|
447
|
+
const jsonlFiles = files.filter((f) => f.endsWith(".jsonl"));
|
|
448
|
+
|
|
449
|
+
if (jsonlFiles.length === 0) return 0;
|
|
450
|
+
|
|
451
|
+
// Get modification times for all session files
|
|
452
|
+
const fileStats = await Promise.all(
|
|
453
|
+
jsonlFiles.map(async (f) => {
|
|
454
|
+
const filePath = join(sessionsDir, f);
|
|
455
|
+
const s = await stat(filePath);
|
|
456
|
+
return { path: filePath, mtime: s.mtimeMs };
|
|
457
|
+
})
|
|
458
|
+
);
|
|
459
|
+
|
|
460
|
+
// Sort oldest first
|
|
461
|
+
fileStats.sort((a, b) => a.mtime - b.mtime);
|
|
462
|
+
|
|
463
|
+
const toDelete = new Set<string>();
|
|
464
|
+
|
|
465
|
+
// Mark files older than maxAgeDays
|
|
466
|
+
if (maxAgeDays !== undefined) {
|
|
467
|
+
const cutoff = Date.now() - maxAgeDays * 24 * 60 * 60 * 1000;
|
|
468
|
+
for (const f of fileStats) {
|
|
469
|
+
if (f.mtime < cutoff) {
|
|
470
|
+
toDelete.add(f.path);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// If still over maxCount after age pruning, trim oldest
|
|
476
|
+
const remaining = fileStats.filter((f) => !toDelete.has(f.path));
|
|
477
|
+
if (remaining.length > maxCount) {
|
|
478
|
+
const excess = remaining.length - maxCount;
|
|
479
|
+
for (let i = 0; i < excess; i++) {
|
|
480
|
+
toDelete.add(remaining[i]!.path);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
for (const filePath of toDelete) {
|
|
485
|
+
try {
|
|
486
|
+
await unlink(filePath);
|
|
487
|
+
} catch {
|
|
488
|
+
// Ignore deletion errors (file may already be gone)
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
return toDelete.size;
|
|
493
|
+
}
|