@gethmy/mcp 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 +201 -36
- package/dist/cli.js +20938 -20249
- package/dist/http.js +1957 -0
- package/dist/index.js +17833 -17888
- package/dist/lib/__tests__/active-learning.test.js +386 -0
- package/dist/lib/__tests__/agent-performance-profiles.test.js +325 -0
- package/dist/lib/__tests__/auto-session.test.js +661 -0
- package/dist/lib/__tests__/context-assembly.test.js +362 -0
- package/dist/lib/__tests__/graph-expansion.test.js +150 -0
- package/dist/lib/__tests__/integration-memory-crud.test.js +797 -0
- package/dist/lib/__tests__/integration-memory-system.test.js +281 -0
- package/dist/lib/__tests__/lifecycle-maintenance.test.js +207 -0
- package/dist/lib/__tests__/pattern-detection.test.js +295 -0
- package/dist/lib/__tests__/prompt-builder.test.js +418 -0
- package/dist/lib/active-learning.js +878 -0
- package/dist/lib/api-client.js +548 -0
- package/dist/lib/auto-session.js +173 -0
- package/dist/lib/cli.js +127 -0
- package/dist/lib/config.js +205 -0
- package/dist/lib/consolidation.js +243 -0
- package/dist/lib/context-assembly.js +606 -0
- package/dist/lib/graph-expansion.js +163 -0
- package/dist/lib/http.js +174 -0
- package/dist/lib/index.js +7 -0
- package/dist/lib/lifecycle-maintenance.js +88 -0
- package/dist/lib/prompt-builder.js +483 -0
- package/dist/lib/remote.js +166 -0
- package/dist/lib/server.js +3132 -0
- package/dist/lib/tui/agents.js +116 -0
- package/dist/lib/tui/docs.js +558 -0
- package/dist/lib/tui/setup.js +1068 -0
- package/dist/lib/tui/theme.js +95 -0
- package/dist/lib/tui/writer.js +200 -0
- package/dist/remote.js +34534 -0
- package/dist/server.js +31967 -0
- package/package.json +20 -7
- package/src/__tests__/active-learning.test.ts +483 -0
- package/src/__tests__/agent-performance-profiles.test.ts +468 -0
- package/src/__tests__/auto-session.test.ts +912 -0
- package/src/__tests__/context-assembly.test.ts +506 -0
- package/src/__tests__/graph-expansion.test.ts +285 -0
- package/src/__tests__/integration-memory-crud.test.ts +948 -0
- package/src/__tests__/integration-memory-system.test.ts +321 -0
- package/src/__tests__/lifecycle-maintenance.test.ts +238 -0
- package/src/__tests__/pattern-detection.test.ts +438 -0
- package/src/__tests__/prompt-builder.test.ts +505 -0
- package/src/active-learning.ts +1227 -0
- package/src/api-client.ts +963 -0
- package/src/auto-session.ts +218 -0
- package/src/cli.ts +166 -0
- package/src/config.ts +285 -0
- package/src/consolidation.ts +314 -0
- package/src/context-assembly.ts +842 -0
- package/src/graph-expansion.ts +234 -0
- package/src/http.ts +265 -0
- package/src/index.ts +8 -0
- package/src/lifecycle-maintenance.ts +120 -0
- package/src/prompt-builder.ts +681 -0
- package/src/remote.ts +227 -0
- package/src/server.ts +3858 -0
- package/src/tui/agents.ts +154 -0
- package/src/tui/docs.ts +650 -0
- package/src/tui/setup.ts +1281 -0
- package/src/tui/theme.ts +114 -0
- package/src/tui/writer.ts +260 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-Session Tracking
|
|
3
|
+
*
|
|
4
|
+
* Automatically detects agent session boundaries by monitoring tool calls.
|
|
5
|
+
* Sessions auto-start when card-mutating tools are called, and auto-end
|
|
6
|
+
* after 10 minutes of inactivity or when a different card is worked on.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { HarmonyApiClient } from "./api-client.js";
|
|
10
|
+
|
|
11
|
+
export interface TrackedSession {
|
|
12
|
+
cardId: string;
|
|
13
|
+
startedAt: number;
|
|
14
|
+
lastActivityAt: number;
|
|
15
|
+
/** If true, session was explicitly started via harmony_start_agent_session */
|
|
16
|
+
isExplicit: boolean;
|
|
17
|
+
agentIdentifier: string;
|
|
18
|
+
agentName: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type EndSessionCallback = (
|
|
22
|
+
client: HarmonyApiClient,
|
|
23
|
+
cardId: string,
|
|
24
|
+
status: "completed" | "paused",
|
|
25
|
+
) => Promise<void>;
|
|
26
|
+
|
|
27
|
+
/** Tools that trigger auto-start of a session */
|
|
28
|
+
export const AUTO_START_TRIGGERS = new Set([
|
|
29
|
+
"harmony_generate_prompt",
|
|
30
|
+
"harmony_update_card",
|
|
31
|
+
"harmony_move_card",
|
|
32
|
+
"harmony_create_subtask",
|
|
33
|
+
"harmony_toggle_subtask",
|
|
34
|
+
"harmony_add_label_to_card",
|
|
35
|
+
"harmony_remove_label_from_card",
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
export const INACTIVITY_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
|
|
39
|
+
const CHECK_INTERVAL_MS = 60 * 1000; // 60 seconds
|
|
40
|
+
|
|
41
|
+
const activeSessions = new Map<string, TrackedSession>();
|
|
42
|
+
let inactivityTimer: ReturnType<typeof setInterval> | null = null;
|
|
43
|
+
let endCallback: EndSessionCallback | null = null;
|
|
44
|
+
let clientGetter: (() => HarmonyApiClient) | null = null;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Initialize auto-session tracking.
|
|
48
|
+
* @param callback Called when an auto-session ends (runs the learning pipeline)
|
|
49
|
+
* @param getClient Function to get the current API client
|
|
50
|
+
*/
|
|
51
|
+
export function initAutoSession(
|
|
52
|
+
callback: EndSessionCallback,
|
|
53
|
+
getClient: () => HarmonyApiClient,
|
|
54
|
+
): void {
|
|
55
|
+
endCallback = callback;
|
|
56
|
+
clientGetter = getClient;
|
|
57
|
+
|
|
58
|
+
if (inactivityTimer) clearInterval(inactivityTimer);
|
|
59
|
+
inactivityTimer = setInterval(checkInactivity, CHECK_INTERVAL_MS);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Track activity on a card. Auto-starts a session if needed.
|
|
64
|
+
*/
|
|
65
|
+
export async function trackActivity(
|
|
66
|
+
cardId: string,
|
|
67
|
+
options?: {
|
|
68
|
+
autoStart?: boolean;
|
|
69
|
+
client?: HarmonyApiClient;
|
|
70
|
+
},
|
|
71
|
+
): Promise<void> {
|
|
72
|
+
const now = Date.now();
|
|
73
|
+
const existing = activeSessions.get(cardId);
|
|
74
|
+
|
|
75
|
+
if (existing) {
|
|
76
|
+
// Update last activity timestamp
|
|
77
|
+
existing.lastActivityAt = now;
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Only auto-start if the tool is a trigger
|
|
82
|
+
if (!options?.autoStart) return;
|
|
83
|
+
|
|
84
|
+
const client = options?.client ?? clientGetter?.();
|
|
85
|
+
if (!client) return;
|
|
86
|
+
|
|
87
|
+
// Collect auto-sessions on other cards to end (avoid mutating map during iteration)
|
|
88
|
+
const toEnd: string[] = [];
|
|
89
|
+
for (const [otherCardId, session] of activeSessions) {
|
|
90
|
+
if (otherCardId !== cardId && !session.isExplicit) {
|
|
91
|
+
toEnd.push(otherCardId);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
for (const otherCardId of toEnd) {
|
|
95
|
+
await autoEndSession(client, otherCardId, "completed");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Start a new auto-session
|
|
99
|
+
try {
|
|
100
|
+
await client.startAgentSession(cardId, {
|
|
101
|
+
agentIdentifier: "auto",
|
|
102
|
+
agentName: "Auto-detected Agent",
|
|
103
|
+
status: "working",
|
|
104
|
+
});
|
|
105
|
+
} catch {
|
|
106
|
+
// Session start failed (might already have one), still track locally
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
activeSessions.set(cardId, {
|
|
110
|
+
cardId,
|
|
111
|
+
startedAt: now,
|
|
112
|
+
lastActivityAt: now,
|
|
113
|
+
isExplicit: false,
|
|
114
|
+
agentIdentifier: "auto",
|
|
115
|
+
agentName: "Auto-detected Agent",
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Mark a session as explicitly started (won't be auto-ended by card switching or inactivity).
|
|
121
|
+
*/
|
|
122
|
+
export function markExplicit(cardId: string): void {
|
|
123
|
+
const existing = activeSessions.get(cardId);
|
|
124
|
+
if (existing) {
|
|
125
|
+
existing.isExplicit = true;
|
|
126
|
+
} else {
|
|
127
|
+
// Track the explicit session even if we didn't auto-start it
|
|
128
|
+
activeSessions.set(cardId, {
|
|
129
|
+
cardId,
|
|
130
|
+
startedAt: Date.now(),
|
|
131
|
+
lastActivityAt: Date.now(),
|
|
132
|
+
isExplicit: true,
|
|
133
|
+
agentIdentifier: "explicit",
|
|
134
|
+
agentName: "Explicit Agent",
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Remove a session from tracking (called when session is explicitly ended).
|
|
141
|
+
*/
|
|
142
|
+
export function untrack(cardId: string): void {
|
|
143
|
+
activeSessions.delete(cardId);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* End all active auto-sessions (called on process shutdown).
|
|
148
|
+
*/
|
|
149
|
+
export async function shutdownAllSessions(): Promise<void> {
|
|
150
|
+
const client = clientGetter?.();
|
|
151
|
+
if (!client) return;
|
|
152
|
+
|
|
153
|
+
// Snapshot keys to avoid mutating map during iteration
|
|
154
|
+
const cardIds = [...activeSessions.keys()];
|
|
155
|
+
const promises = cardIds.map((cardId) =>
|
|
156
|
+
autoEndSession(client, cardId, "paused"),
|
|
157
|
+
);
|
|
158
|
+
await Promise.allSettled(promises);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Clean up the interval timer (for tests).
|
|
163
|
+
*/
|
|
164
|
+
export function destroyAutoSession(): void {
|
|
165
|
+
if (inactivityTimer) {
|
|
166
|
+
clearInterval(inactivityTimer);
|
|
167
|
+
inactivityTimer = null;
|
|
168
|
+
}
|
|
169
|
+
activeSessions.clear();
|
|
170
|
+
endCallback = null;
|
|
171
|
+
clientGetter = null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Get a snapshot of active sessions (for testing/debugging).
|
|
176
|
+
*/
|
|
177
|
+
export function getActiveSessions(): Map<string, TrackedSession> {
|
|
178
|
+
return activeSessions;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Run inactivity check immediately (exported for testing).
|
|
183
|
+
* In production, called by the setInterval timer every 60s.
|
|
184
|
+
*/
|
|
185
|
+
export function checkInactivity(): void {
|
|
186
|
+
const now = Date.now();
|
|
187
|
+
const client = clientGetter?.();
|
|
188
|
+
if (!client) return;
|
|
189
|
+
|
|
190
|
+
// Snapshot keys to avoid mutating map during iteration
|
|
191
|
+
const entries = [...activeSessions.entries()];
|
|
192
|
+
for (const [cardId, session] of entries) {
|
|
193
|
+
if (session.isExplicit) continue;
|
|
194
|
+
if (now - session.lastActivityAt > INACTIVITY_TIMEOUT_MS) {
|
|
195
|
+
autoEndSession(client, cardId, "completed").catch(() => {});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// --- Internal ---
|
|
201
|
+
|
|
202
|
+
async function autoEndSession(
|
|
203
|
+
client: HarmonyApiClient,
|
|
204
|
+
cardId: string,
|
|
205
|
+
status: "completed" | "paused",
|
|
206
|
+
): Promise<void> {
|
|
207
|
+
activeSessions.delete(cardId);
|
|
208
|
+
try {
|
|
209
|
+
await client.endAgentSession(cardId, { status });
|
|
210
|
+
} catch {
|
|
211
|
+
// Best-effort end
|
|
212
|
+
}
|
|
213
|
+
try {
|
|
214
|
+
await endCallback?.(client, cardId, status);
|
|
215
|
+
} catch {
|
|
216
|
+
// Best-effort pipeline
|
|
217
|
+
}
|
|
218
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { program } from "commander";
|
|
4
|
+
import {
|
|
5
|
+
areSkillsInstalled,
|
|
6
|
+
getActiveProjectId,
|
|
7
|
+
getActiveWorkspaceId,
|
|
8
|
+
getConfigPath,
|
|
9
|
+
getLocalConfigPath,
|
|
10
|
+
hasLocalConfig,
|
|
11
|
+
isConfigured,
|
|
12
|
+
loadConfig,
|
|
13
|
+
loadLocalConfig,
|
|
14
|
+
saveConfig,
|
|
15
|
+
} from "./config.js";
|
|
16
|
+
import { HarmonyMCPServer } from "./server.js";
|
|
17
|
+
import { runSetup } from "./tui/setup.js";
|
|
18
|
+
|
|
19
|
+
const require = createRequire(import.meta.url);
|
|
20
|
+
const { version } = require("../package.json");
|
|
21
|
+
|
|
22
|
+
program
|
|
23
|
+
.name("@gethmy/mcp")
|
|
24
|
+
.description("MCP server for Harmony Kanban board")
|
|
25
|
+
.version(version);
|
|
26
|
+
|
|
27
|
+
program
|
|
28
|
+
.command("serve")
|
|
29
|
+
.description("Start the MCP server (stdio transport)")
|
|
30
|
+
.action(async () => {
|
|
31
|
+
const server = new HarmonyMCPServer();
|
|
32
|
+
await server.run();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
program
|
|
36
|
+
.command("status")
|
|
37
|
+
.description("Show configuration status")
|
|
38
|
+
.action(() => {
|
|
39
|
+
const globalConfig = loadConfig();
|
|
40
|
+
const localConfig = loadLocalConfig();
|
|
41
|
+
const hasLocal = hasLocalConfig();
|
|
42
|
+
const skillsStatus = areSkillsInstalled();
|
|
43
|
+
|
|
44
|
+
// Helper to mask email for privacy (u***@example.com)
|
|
45
|
+
const maskEmail = (email: string): string => {
|
|
46
|
+
const [local, domain] = email.split("@");
|
|
47
|
+
if (!domain) return email;
|
|
48
|
+
return `${local[0]}***@${domain}`;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
if (isConfigured()) {
|
|
52
|
+
console.log("Status: Configured\n");
|
|
53
|
+
|
|
54
|
+
console.log("API:");
|
|
55
|
+
console.log(` Key: ${globalConfig.apiKey?.slice(0, 8)}...`);
|
|
56
|
+
console.log(` URL: ${globalConfig.apiUrl}`);
|
|
57
|
+
console.log(
|
|
58
|
+
` Email: ${globalConfig.userEmail ? maskEmail(globalConfig.userEmail) : "(not set)"}`,
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
console.log("\nSkills:");
|
|
62
|
+
if (skillsStatus.installed) {
|
|
63
|
+
console.log(` Installed: Yes (${skillsStatus.location})`);
|
|
64
|
+
for (const path of skillsStatus.paths) {
|
|
65
|
+
console.log(` ${path}`);
|
|
66
|
+
}
|
|
67
|
+
} else {
|
|
68
|
+
console.log(" Installed: No");
|
|
69
|
+
console.log(" Run: npx @gethmy/mcp setup");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
console.log("\nContext:");
|
|
73
|
+
|
|
74
|
+
if (hasLocal) {
|
|
75
|
+
console.log(` Local config: ${getLocalConfigPath()}`);
|
|
76
|
+
console.log(
|
|
77
|
+
` Workspace: ${localConfig?.workspaceId || "(not set)"}`,
|
|
78
|
+
);
|
|
79
|
+
console.log(` Project: ${localConfig?.projectId || "(not set)"}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
console.log(` Global config: ${getConfigPath()}`);
|
|
83
|
+
console.log(
|
|
84
|
+
` Workspace: ${globalConfig.activeWorkspaceId || "(not set)"}`,
|
|
85
|
+
);
|
|
86
|
+
console.log(
|
|
87
|
+
` Project: ${globalConfig.activeProjectId || "(not set)"}`,
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
// Show effective (active) context
|
|
91
|
+
const effectiveWorkspace = getActiveWorkspaceId();
|
|
92
|
+
const effectiveProject = getActiveProjectId();
|
|
93
|
+
const wsSource = localConfig?.workspaceId
|
|
94
|
+
? "local"
|
|
95
|
+
: globalConfig.activeWorkspaceId
|
|
96
|
+
? "global"
|
|
97
|
+
: "";
|
|
98
|
+
const projSource = localConfig?.projectId
|
|
99
|
+
? "local"
|
|
100
|
+
: globalConfig.activeProjectId
|
|
101
|
+
? "global"
|
|
102
|
+
: "";
|
|
103
|
+
|
|
104
|
+
console.log("\n Active (effective):");
|
|
105
|
+
console.log(
|
|
106
|
+
` Workspace: ${effectiveWorkspace || "(not set)"}${wsSource ? ` ← ${wsSource}` : ""}`,
|
|
107
|
+
);
|
|
108
|
+
console.log(
|
|
109
|
+
` Project: ${effectiveProject || "(not set)"}${projSource ? ` ← ${projSource}` : ""}`,
|
|
110
|
+
);
|
|
111
|
+
} else {
|
|
112
|
+
console.log("Status: Not configured\n");
|
|
113
|
+
console.log("Run: npx @gethmy/mcp setup");
|
|
114
|
+
console.log("Get an API key at: https://gethmy.com/user/keys");
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
program
|
|
119
|
+
.command("reset")
|
|
120
|
+
.description("Remove stored configuration")
|
|
121
|
+
.action(() => {
|
|
122
|
+
saveConfig({
|
|
123
|
+
apiKey: null,
|
|
124
|
+
activeWorkspaceId: null,
|
|
125
|
+
activeProjectId: null,
|
|
126
|
+
userEmail: null,
|
|
127
|
+
});
|
|
128
|
+
console.log("Configuration reset successfully");
|
|
129
|
+
console.log("\nTo reconfigure, run: npx @gethmy/mcp setup");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
program
|
|
133
|
+
.command("setup")
|
|
134
|
+
.description("Smart setup wizard for Harmony MCP (recommended)")
|
|
135
|
+
.option("-f, --force", "Overwrite existing configuration files")
|
|
136
|
+
.option("-k, --api-key <key>", "API key (skips prompt)")
|
|
137
|
+
.option("-e, --email <email>", "Your email for auto-assignment")
|
|
138
|
+
.option(
|
|
139
|
+
"-a, --agents <agents...>",
|
|
140
|
+
"Agents to configure: claude, codex, cursor, windsurf",
|
|
141
|
+
)
|
|
142
|
+
.option("-l, --local", "Install skills locally in project directory")
|
|
143
|
+
.option("-g, --global", "Install skills globally (recommended)")
|
|
144
|
+
.option("-w, --workspace <id>", "Set workspace context")
|
|
145
|
+
.option("-p, --project <id>", "Set project context")
|
|
146
|
+
.option("--skip-context", "Skip workspace/project selection")
|
|
147
|
+
.option("--skip-docs", "Skip project docs scaffold/verification")
|
|
148
|
+
.action(async (options) => {
|
|
149
|
+
await runSetup({
|
|
150
|
+
force: options.force,
|
|
151
|
+
apiKey: options.apiKey,
|
|
152
|
+
userEmail: options.email,
|
|
153
|
+
agents: options.agents,
|
|
154
|
+
installMode: options.global
|
|
155
|
+
? "global"
|
|
156
|
+
: options.local
|
|
157
|
+
? "local"
|
|
158
|
+
: undefined,
|
|
159
|
+
workspaceId: options.workspace,
|
|
160
|
+
projectId: options.project,
|
|
161
|
+
skipContext: options.skipContext,
|
|
162
|
+
skipDocs: options.skipDocs,
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
program.parse();
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
export interface HarmonyConfig {
|
|
6
|
+
apiKey: string | null;
|
|
7
|
+
apiUrl: string;
|
|
8
|
+
activeWorkspaceId: string | null;
|
|
9
|
+
activeProjectId: string | null;
|
|
10
|
+
userEmail: string | null;
|
|
11
|
+
memoryDir: string | null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Local project-level config (stored in .harmony-mcp.json in project root).
|
|
16
|
+
* Only contains context IDs - API key stays global for security.
|
|
17
|
+
*/
|
|
18
|
+
export interface LocalConfig {
|
|
19
|
+
workspaceId: string | null;
|
|
20
|
+
projectId: string | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const DEFAULT_API_URL = "https://gethmy.com/api";
|
|
24
|
+
const LOCAL_CONFIG_FILENAME = ".harmony-mcp.json";
|
|
25
|
+
|
|
26
|
+
export function getConfigDir(): string {
|
|
27
|
+
return join(homedir(), ".harmony-mcp");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function getConfigPath(): string {
|
|
31
|
+
return join(getConfigDir(), "config.json");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function getLocalConfigPath(cwd?: string): string {
|
|
35
|
+
return join(cwd || process.cwd(), LOCAL_CONFIG_FILENAME);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function loadConfig(): HarmonyConfig {
|
|
39
|
+
const configPath = getConfigPath();
|
|
40
|
+
|
|
41
|
+
if (!existsSync(configPath)) {
|
|
42
|
+
return {
|
|
43
|
+
apiKey: null,
|
|
44
|
+
apiUrl: DEFAULT_API_URL,
|
|
45
|
+
activeWorkspaceId: null,
|
|
46
|
+
activeProjectId: null,
|
|
47
|
+
userEmail: null,
|
|
48
|
+
memoryDir: null,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
const data = readFileSync(configPath, "utf-8");
|
|
54
|
+
const config = JSON.parse(data);
|
|
55
|
+
return {
|
|
56
|
+
apiKey: config.apiKey || null,
|
|
57
|
+
apiUrl: config.apiUrl || DEFAULT_API_URL,
|
|
58
|
+
activeWorkspaceId: config.activeWorkspaceId || null,
|
|
59
|
+
activeProjectId: config.activeProjectId || null,
|
|
60
|
+
userEmail: config.userEmail || null,
|
|
61
|
+
memoryDir: config.memoryDir || null,
|
|
62
|
+
};
|
|
63
|
+
} catch {
|
|
64
|
+
return {
|
|
65
|
+
apiKey: null,
|
|
66
|
+
apiUrl: DEFAULT_API_URL,
|
|
67
|
+
activeWorkspaceId: null,
|
|
68
|
+
activeProjectId: null,
|
|
69
|
+
userEmail: null,
|
|
70
|
+
memoryDir: null,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function saveConfig(config: Partial<HarmonyConfig>): void {
|
|
76
|
+
const configDir = getConfigDir();
|
|
77
|
+
const configPath = getConfigPath();
|
|
78
|
+
|
|
79
|
+
if (!existsSync(configDir)) {
|
|
80
|
+
mkdirSync(configDir, { recursive: true, mode: 0o700 });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const existingConfig = loadConfig();
|
|
84
|
+
const newConfig = { ...existingConfig, ...config };
|
|
85
|
+
|
|
86
|
+
writeFileSync(configPath, JSON.stringify(newConfig, null, 2), {
|
|
87
|
+
mode: 0o600,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function loadLocalConfig(cwd?: string): LocalConfig | null {
|
|
92
|
+
const localConfigPath = getLocalConfigPath(cwd);
|
|
93
|
+
|
|
94
|
+
if (!existsSync(localConfigPath)) {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
const data = readFileSync(localConfigPath, "utf-8");
|
|
100
|
+
const config = JSON.parse(data);
|
|
101
|
+
return {
|
|
102
|
+
workspaceId: config.workspaceId || null,
|
|
103
|
+
projectId: config.projectId || null,
|
|
104
|
+
};
|
|
105
|
+
} catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function saveLocalConfig(
|
|
111
|
+
config: Partial<LocalConfig>,
|
|
112
|
+
cwd?: string,
|
|
113
|
+
): void {
|
|
114
|
+
const localConfigPath = getLocalConfigPath(cwd);
|
|
115
|
+
|
|
116
|
+
const existingConfig = loadLocalConfig(cwd) || {
|
|
117
|
+
workspaceId: null,
|
|
118
|
+
projectId: null,
|
|
119
|
+
};
|
|
120
|
+
const newConfig = { ...existingConfig, ...config };
|
|
121
|
+
|
|
122
|
+
// Remove null values from the saved config for cleaner output
|
|
123
|
+
const cleanConfig: Record<string, string> = {};
|
|
124
|
+
if (newConfig.workspaceId) cleanConfig.workspaceId = newConfig.workspaceId;
|
|
125
|
+
if (newConfig.projectId) cleanConfig.projectId = newConfig.projectId;
|
|
126
|
+
|
|
127
|
+
writeFileSync(localConfigPath, JSON.stringify(cleanConfig, null, 2));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function hasLocalConfig(cwd?: string): boolean {
|
|
131
|
+
return existsSync(getLocalConfigPath(cwd));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function getApiKey(): string {
|
|
135
|
+
const config = loadConfig();
|
|
136
|
+
if (!config.apiKey) {
|
|
137
|
+
throw new Error(
|
|
138
|
+
'Not configured. Run "npx @gethmy/mcp setup" to set your API key.\n' +
|
|
139
|
+
"You can generate an API key at https://gethmy.com → Settings → API Keys.",
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
return config.apiKey;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function getApiUrl(): string {
|
|
146
|
+
const config = loadConfig();
|
|
147
|
+
return config.apiUrl;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function getUserEmail(): string | null {
|
|
151
|
+
const config = loadConfig();
|
|
152
|
+
return config.userEmail;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function setUserEmail(email: string | null): void {
|
|
156
|
+
saveConfig({ userEmail: email });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export interface SetContextOptions {
|
|
160
|
+
local?: boolean;
|
|
161
|
+
cwd?: string;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function setActiveWorkspace(
|
|
165
|
+
workspaceId: string | null,
|
|
166
|
+
options?: SetContextOptions,
|
|
167
|
+
): void {
|
|
168
|
+
if (options?.local) {
|
|
169
|
+
saveLocalConfig({ workspaceId }, options.cwd);
|
|
170
|
+
} else {
|
|
171
|
+
saveConfig({ activeWorkspaceId: workspaceId });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function setActiveProject(
|
|
176
|
+
projectId: string | null,
|
|
177
|
+
options?: SetContextOptions,
|
|
178
|
+
): void {
|
|
179
|
+
if (options?.local) {
|
|
180
|
+
saveLocalConfig({ projectId }, options.cwd);
|
|
181
|
+
} else {
|
|
182
|
+
saveConfig({ activeProjectId: projectId });
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function getActiveWorkspaceId(cwd?: string): string | null {
|
|
187
|
+
// Local config takes precedence over global
|
|
188
|
+
const localConfig = loadLocalConfig(cwd);
|
|
189
|
+
if (localConfig?.workspaceId) {
|
|
190
|
+
return localConfig.workspaceId;
|
|
191
|
+
}
|
|
192
|
+
return loadConfig().activeWorkspaceId;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function getActiveProjectId(cwd?: string): string | null {
|
|
196
|
+
// Local config takes precedence over global
|
|
197
|
+
const localConfig = loadLocalConfig(cwd);
|
|
198
|
+
if (localConfig?.projectId) {
|
|
199
|
+
return localConfig.projectId;
|
|
200
|
+
}
|
|
201
|
+
return loadConfig().activeProjectId;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function isConfigured(): boolean {
|
|
205
|
+
const config = loadConfig();
|
|
206
|
+
return !!config.apiKey;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Check if skills are already installed (globally or locally).
|
|
211
|
+
* Returns installation status and location.
|
|
212
|
+
*/
|
|
213
|
+
export function areSkillsInstalled(cwd?: string): {
|
|
214
|
+
installed: boolean;
|
|
215
|
+
location: "global" | "local" | null;
|
|
216
|
+
paths: string[];
|
|
217
|
+
} {
|
|
218
|
+
const home = homedir();
|
|
219
|
+
const workingDir = cwd || process.cwd();
|
|
220
|
+
const foundPaths: string[] = [];
|
|
221
|
+
|
|
222
|
+
// Check global skills directory
|
|
223
|
+
const globalSkillsDir = join(home, ".agents", "skills");
|
|
224
|
+
const globalSkillPath = join(globalSkillsDir, "hmy", "SKILL.md");
|
|
225
|
+
if (existsSync(globalSkillPath)) {
|
|
226
|
+
foundPaths.push(globalSkillPath);
|
|
227
|
+
return { installed: true, location: "global", paths: foundPaths };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Check Claude global skills (symlinked from global skills)
|
|
231
|
+
const claudeGlobalSkill = join(home, ".claude", "skills", "hmy.md");
|
|
232
|
+
if (existsSync(claudeGlobalSkill)) {
|
|
233
|
+
foundPaths.push(claudeGlobalSkill);
|
|
234
|
+
return { installed: true, location: "global", paths: foundPaths };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Check Claude global skills (alternate SKILL.md format)
|
|
238
|
+
const claudeGlobalSkillAlt = join(
|
|
239
|
+
home,
|
|
240
|
+
".claude",
|
|
241
|
+
"skills",
|
|
242
|
+
"hmy",
|
|
243
|
+
"SKILL.md",
|
|
244
|
+
);
|
|
245
|
+
if (existsSync(claudeGlobalSkillAlt)) {
|
|
246
|
+
foundPaths.push(claudeGlobalSkillAlt);
|
|
247
|
+
return { installed: true, location: "global", paths: foundPaths };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Check local skills in project directory
|
|
251
|
+
const localSkillPath = join(workingDir, ".claude", "skills", "hmy.md");
|
|
252
|
+
if (existsSync(localSkillPath)) {
|
|
253
|
+
foundPaths.push(localSkillPath);
|
|
254
|
+
return { installed: true, location: "local", paths: foundPaths };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Check local skills in project directory (alternate SKILL.md format)
|
|
258
|
+
const localSkillPathAlt = join(
|
|
259
|
+
workingDir,
|
|
260
|
+
".claude",
|
|
261
|
+
"skills",
|
|
262
|
+
"hmy",
|
|
263
|
+
"SKILL.md",
|
|
264
|
+
);
|
|
265
|
+
if (existsSync(localSkillPathAlt)) {
|
|
266
|
+
foundPaths.push(localSkillPathAlt);
|
|
267
|
+
return { installed: true, location: "local", paths: foundPaths };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return { installed: false, location: null, paths: [] };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Check if project context is configured in the local directory.
|
|
275
|
+
*/
|
|
276
|
+
export function hasProjectContext(cwd?: string): boolean {
|
|
277
|
+
const localConfig = loadLocalConfig(cwd);
|
|
278
|
+
return !!(localConfig?.workspaceId || localConfig?.projectId);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export function getMemoryDir(): string {
|
|
282
|
+
const config = loadConfig();
|
|
283
|
+
if (config.memoryDir) return config.memoryDir;
|
|
284
|
+
return join(homedir(), ".harmony", "memory");
|
|
285
|
+
}
|