@gethmy/mcp 3.5.0 → 3.7.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/src/config.ts CHANGED
@@ -22,7 +22,7 @@ export interface HarmonyConfig {
22
22
  }
23
23
 
24
24
  /**
25
- * Local project-level config (stored in .harmony-mcp.json in project root).
25
+ * Local project-level config (stored in .hmy.json in project root).
26
26
  * Only contains context IDs - API key stays global for security.
27
27
  */
28
28
  export interface LocalConfig {
@@ -31,22 +31,131 @@ export interface LocalConfig {
31
31
  }
32
32
 
33
33
  const DEFAULT_API_URL = "https://app.gethmy.com/api";
34
- const LOCAL_CONFIG_FILENAME = ".harmony-mcp.json";
35
34
 
35
+ /**
36
+ * The config surface was renamed to `hmy` in card #1082 — `.harmony-mcp.json`
37
+ * became `.hmy.json`, and the home directory `~/.harmony-mcp/` became
38
+ * `~/.hmy/agent/`.
39
+ *
40
+ * `~/.hmy` already existed and belongs to the skill auto-update layer
41
+ * (`config.yaml`, `VERSION`, `bin/`, see `hmy-config.ts`), so the daemon's own
42
+ * state took the `agent/` subdirectory rather than landing a second file named
43
+ * `config.*` — in a different format, with a different owner — beside it.
44
+ *
45
+ * **Writes always go to the new name; reads fall back to the old one.**
46
+ * `@gethmy/mcp` and `@gethmy/agent` are published and the daemon runs from a
47
+ * frozen npx cache, so a hard cut would leave a running daemon unable to find
48
+ * its config until a coordinated release. `migrateConfigDir` (harmony-agent)
49
+ * moves the data once, at daemon startup, which makes the fallback moot in
50
+ * practice — it stays only for a client that never runs the daemon.
51
+ *
52
+ * The legacy spellings are exported because two things still need them after
53
+ * the fallback is removed: the migration, and the harness read-denylists. The
54
+ * old directory keeps holding an API key and 3430 run logs until an operator
55
+ * deletes it, so denying only the new name would OPEN it. See
56
+ * `credentialDirectories()` in harmony-harness and `HARNESS_CREDENTIAL_LEAVES`
57
+ * in `@harmony/shared`'s `runRedaction.ts`; both name old and new.
58
+ */
59
+ const LOCAL_CONFIG_FILENAME = ".hmy.json";
60
+ export const LEGACY_LOCAL_CONFIG_FILENAME = ".harmony-mcp.json";
61
+ const CONFIG_DIR_NAME = ".hmy";
62
+ const CONFIG_DIR_SUBDIR = "agent";
63
+ const LEGACY_CONFIG_DIR_NAME = ".harmony-mcp";
64
+
65
+ /**
66
+ * Fallbacks announce themselves ONCE per process (#1082).
67
+ *
68
+ * A silent fallback is the failure mode this whole compatibility window is
69
+ * exposed to: an operator whose migration failed, or whose daemon runs from a
70
+ * frozen npx cache, gets no signal that they still depend on a path scheduled
71
+ * for removal — and then the follow-up card that deletes the fallback breaks
72
+ * them with nothing in any log to connect the two.
73
+ *
74
+ * Latched per process rather than per call because these sit on hot paths —
75
+ * `loadConfig` runs on every tool dispatch — and a line repeated hundreds of
76
+ * times is read as noise and filtered out, which is the same as silence.
77
+ *
78
+ * `console.error`, not `console.log`: on stdio transport stdout carries the
79
+ * JSON-RPC protocol, so anything printed there corrupts the session. Same
80
+ * convention as `skills.ts`.
81
+ */
82
+ let warnedLegacyConfigDir = false;
83
+ let warnedLegacyLocalPin = false;
84
+
85
+ /**
86
+ * Clear both latches. Tests only.
87
+ *
88
+ * A per-process latch is not observable twice, so without this the "said once"
89
+ * half of the contract is untestable — and an untested notice is how the
90
+ * fallback goes silent again without anyone noticing. Exported deliberately
91
+ * rather than left to a test reaching into module state.
92
+ */
93
+ export function resetLegacyNoticesForTest(): void {
94
+ warnedLegacyConfigDir = false;
95
+ warnedLegacyLocalPin = false;
96
+ }
97
+
98
+ function noteLegacyConfigDir(path: string): void {
99
+ if (warnedLegacyConfigDir) return;
100
+ warnedLegacyConfigDir = true;
101
+ console.error(
102
+ `Harmony: reading the pre-#1082 config at ${path}. ` +
103
+ `The current location is ${getConfigPath()}; ` +
104
+ `run the agent daemon once to migrate, or move the file yourself.`,
105
+ );
106
+ }
107
+
108
+ /** Said once when a repo pin is read under the old name. */
109
+ export function noteLegacyLocalPin(path: string): void {
110
+ if (warnedLegacyLocalPin) return;
111
+ warnedLegacyLocalPin = true;
112
+ console.error(
113
+ `Harmony: this repo is pinned by ${path}, the pre-#1082 name. ` +
114
+ `Rename it to ${LOCAL_CONFIG_FILENAME} — the fallback that finds it is temporary.`,
115
+ );
116
+ }
117
+
118
+ /** Said once when a write moves the pin to the current name. */
119
+ function noteLocalPinRename(from: string, to: string): void {
120
+ console.error(
121
+ `Harmony: wrote the repo pin to ${to} (the pre-#1082 ${from} is now ignored and can be deleted).`,
122
+ );
123
+ }
124
+
125
+ /**
126
+ * `~/.hmy` — the root the whole `hmy` surface shares. Named separately from
127
+ * `getConfigDir()` because the denylists want the WHOLE tree, not just the
128
+ * daemon's corner of it: `config.yaml` is not a credential, but a future
129
+ * sibling might be, and a directory has no spellings to enumerate.
130
+ */
131
+ export function getHmyRootDir(): string {
132
+ return join(homedir(), CONFIG_DIR_NAME);
133
+ }
134
+
135
+ /** `~/.hmy/agent` — where every writer writes. Never falls back. */
36
136
  export function getConfigDir(): string {
37
- return join(homedir(), ".harmony-mcp");
137
+ return join(getHmyRootDir(), CONFIG_DIR_SUBDIR);
138
+ }
139
+
140
+ /** `~/.harmony-mcp` — the pre-#1082 location. Read-only, and denied forever. */
141
+ export function getLegacyConfigDir(): string {
142
+ return join(homedir(), LEGACY_CONFIG_DIR_NAME);
38
143
  }
39
144
 
40
145
  export function getConfigPath(): string {
41
146
  return join(getConfigDir(), "config.json");
42
147
  }
43
148
 
149
+ export function getLegacyConfigPath(): string {
150
+ return join(getLegacyConfigDir(), "config.json");
151
+ }
152
+
44
153
  export function getLocalConfigPath(cwd?: string): string {
45
154
  return join(cwd || process.cwd(), LOCAL_CONFIG_FILENAME);
46
155
  }
47
156
 
48
157
  /**
49
- * Find the nearest `.harmony-mcp.json` at or above `cwd` (card #893).
158
+ * Find the nearest `.hmy.json` at or above `cwd` (card #893).
50
159
  *
51
160
  * The old behaviour looked in the exact cwd and nowhere else, so a server
52
161
  * started from a package directory or a git worktree never saw the repo's pin
@@ -56,9 +165,15 @@ export function getLocalConfigPath(cwd?: string): string {
56
165
  * Two directories are deliberately skipped, however deep the walk goes:
57
166
  *
58
167
  * - **the home directory** — a stray file there would capture every session in
59
- * every repo, and `~/.harmony-mcp/config.json` is already the global pin;
168
+ * every repo, and `~/.hmy/agent/config.json` is already the global pin;
60
169
  * - **the filesystem root** — same reasoning, machine-wide.
61
170
  *
171
+ * **Both names are checked in the SAME directory before walking up** (#1082),
172
+ * which is the only ordering that preserves #893. Checking every ancestor for
173
+ * `.hmy.json` first and only then re-walking for `.harmony-mcp.json` would let
174
+ * a parent repo's new-name pin beat the current repo's own legacy pin — the
175
+ * nearest file must win whichever name it carries.
176
+ *
62
177
  * Returns `null` when no ancestor carries one.
63
178
  */
64
179
  export function findLocalConfigPath(cwd?: string): string | null {
@@ -68,8 +183,13 @@ export function findLocalConfigPath(cwd?: string): string | null {
68
183
 
69
184
  for (;;) {
70
185
  if (dir !== home && dir !== root) {
71
- const candidate = join(dir, LOCAL_CONFIG_FILENAME);
72
- if (existsSync(candidate)) return candidate;
186
+ const current = join(dir, LOCAL_CONFIG_FILENAME);
187
+ if (existsSync(current)) return current;
188
+ const legacy = join(dir, LEGACY_LOCAL_CONFIG_FILENAME);
189
+ if (existsSync(legacy)) {
190
+ noteLegacyLocalPin(legacy);
191
+ return legacy;
192
+ }
73
193
  }
74
194
  const parent = dirname(dir);
75
195
  if (parent === dir) return null;
@@ -93,10 +213,14 @@ function emptyConfig(): HarmonyConfig {
93
213
  }
94
214
 
95
215
  export function loadConfig(): HarmonyConfig {
96
- const configPath = getConfigPath();
97
-
216
+ // Read the new location, fall back to the pre-#1082 one. The fallback is
217
+ // read-only on purpose: `saveConfig` always writes the new path, so the first
218
+ // write after an upgrade lands there and the old file stops being consulted.
219
+ let configPath = getConfigPath();
98
220
  if (!existsSync(configPath)) {
99
- return emptyConfig();
221
+ configPath = getLegacyConfigPath();
222
+ if (!existsSync(configPath)) return emptyConfig();
223
+ noteLegacyConfigDir(configPath);
100
224
  }
101
225
 
102
226
  try {
@@ -157,14 +281,33 @@ export function loadLocalConfig(cwd?: string): LocalConfig | null {
157
281
  }
158
282
  }
159
283
 
284
+ /** Returns the path actually written, which is NOT always `<cwd>/.hmy.json`. */
160
285
  export function saveLocalConfig(
161
286
  config: Partial<LocalConfig>,
162
287
  cwd?: string,
163
- ): void {
164
- // Write back to the file we READ, not to the cwd: a `set_project_context`
165
- // issued from a package directory must update the repo's pin rather than
166
- // strand a second config file the repo root never looks at (card #893).
167
- const localConfigPath = findLocalConfigPath(cwd) ?? getLocalConfigPath(cwd);
288
+ ): string {
289
+ // Write back to the DIRECTORY we read from, under the CURRENT name (#893,
290
+ // #1082). #893's rule is about the directory: a `set_project_context` issued
291
+ // from a package subdirectory must update the repo's pin rather than strand a
292
+ // second config file the repo root never looks at. The filename was never the
293
+ // point, and writing back to `.harmony-mcp.json` because that is what the read
294
+ // found would make this the one writer still creating the old name — the
295
+ // thing #1082 exists to stop, and a live trap for the card that removes the
296
+ // read fallback, since a pin left under the old name goes invisible and the
297
+ // session falls through to the global context. That silent wrong-workspace
298
+ // failure is exactly what #893 was filed to remove.
299
+ //
300
+ // The legacy file is left on disk rather than deleted, matching
301
+ // `migrateConfigDir`'s doctrine: this code removes nothing an operator wrote.
302
+ // It is inert from the next read on — `findLocalConfigPath` prefers
303
+ // `.hmy.json` in the same directory — and `noteLocalPinRename` says so once.
304
+ const foundPath = findLocalConfigPath(cwd);
305
+ const localConfigPath = foundPath
306
+ ? join(dirname(foundPath), LOCAL_CONFIG_FILENAME)
307
+ : getLocalConfigPath(cwd);
308
+ if (foundPath !== null && foundPath !== localConfigPath) {
309
+ noteLocalPinRename(foundPath, localConfigPath);
310
+ }
168
311
 
169
312
  const existingConfig = loadLocalConfig(cwd) || {
170
313
  workspaceId: null,
@@ -178,6 +321,7 @@ export function saveLocalConfig(
178
321
  if (newConfig.projectId) cleanConfig.projectId = newConfig.projectId;
179
322
 
180
323
  writeFileSync(localConfigPath, JSON.stringify(cleanConfig, null, 2));
324
+ return localConfigPath;
181
325
  }
182
326
 
183
327
  export function hasLocalConfig(cwd?: string): boolean {
@@ -222,10 +366,10 @@ export function setUserEmail(email: string | null): void {
222
366
  }
223
367
 
224
368
  export interface SetContextOptions {
225
- /** Force a write to the repo-local `.harmony-mcp.json` (creating it). */
369
+ /** Force a write to the repo-local `.hmy.json` (creating it). */
226
370
  local?: boolean;
227
371
  /**
228
- * Force a write to the global `~/.harmony-mcp/config.json`, even when a local
372
+ * Force a write to the global `~/.hmy/agent/config.json`, even when a local
229
373
  * file exists. Setup uses this to mirror the chosen context into the global
230
374
  * default; without it the mirror would land back in the local file it just
231
375
  * wrote, leaving every server started from another directory with no context.
@@ -318,7 +462,7 @@ export function setActiveWorkspace(
318
462
  /**
319
463
  * The active pair, read from ONE source (card #893).
320
464
  *
321
- * A local `.harmony-mcp.json` wins **as a whole file**, not field by field. The
465
+ * A local `.hmy.json` wins **as a whole file**, not field by field. The
322
466
  * two ids used to fall back to the global config independently, which quietly
323
467
  * re-created the very mismatch this card removes: `saveLocalConfig` omits null
324
468
  * values, so writing `projectId: null` locally did not clear the project — it
@@ -12,7 +12,7 @@
12
12
  * `hooks` is not merely missing from that list — it is the key that BROKE the
13
13
  * previous denylist and forced the inversion. A hook block in a run's own
14
14
  * worktree executed as the daemon user, outside the sandbox, and read
15
- * `~/.ssh/config` and `~/.harmony-mcp/config.json`. So writing this hook into a
15
+ * `~/.ssh/config` and `~/.hmy/agent/config.json`. So writing this hook into a
16
16
  * project settings file would throw `ProjectSandboxOverrideError` on every
17
17
  * contained implement run in that repo — it would not degrade, it would stop
18
18
  * the daemon.
@@ -33,7 +33,7 @@ interface RefreshResponse {
33
33
  let inFlight: Promise<string | null> | null = null;
34
34
 
35
35
  // Cross-process serialization. Every Claude Code session spawns its own stdio
36
- // MCP process, and they all share ~/.harmony-mcp/config.json. The refresh
36
+ // MCP process, and they all share ~/.hmy/agent/config.json. The refresh
37
37
  // token rotates on use, so two processes refreshing concurrently would each
38
38
  // POST a refresh: the first consumes the token, the second replays a now-
39
39
  // consumed token and trips the server's reuse detection, revoking the whole
package/src/run-hook.ts CHANGED
@@ -32,7 +32,7 @@
32
32
  * This module is pure. The process that calls it does the I/O.
33
33
  */
34
34
 
35
- import { redactToolCall } from "./run-redaction.js";
35
+ import { redactToolCall } from "@harmony/shared";
36
36
 
37
37
  /**
38
38
  * The subset of the harness's `PostToolUse` stdin payload this reads.
package/src/run-state.ts CHANGED
@@ -14,7 +14,7 @@
14
14
  * server's memory, so the session identity has to travel **through the
15
15
  * filesystem**. That is what this module is.
16
16
  *
17
- * ## Why not `~/.harmony-mcp/`
17
+ * ## Why not `~/.hmy/agent/`
18
18
  *
19
19
  * `getConfigDir()` is the FIRST entry of `credentialDirectories()`
20
20
  * (`packages/harmony-harness/src/run-containment.ts`) and is read-denied to
package/src/server.ts CHANGED
@@ -3293,7 +3293,7 @@ export async function handleToolCall(
3293
3293
  // deliberately-set active project is a hard scope: `found` there means
3294
3294
  // the card was already in it, so there is nothing to change — and we
3295
3295
  // never let a read silently repoint a context the user chose (which,
3296
- // on the local stdio MCP, persists to ~/.harmony-mcp/config.json
3296
+ // on the local stdio MCP, persists to ~/.hmy/agent/config.json
3297
3297
  // across sessions). Confirm the target back either way so a
3298
3298
  // wrong-context resolve is visible immediately.
3299
3299
  const established = activeProjectId == null;
package/src/tui/setup.ts CHANGED
@@ -1552,6 +1552,9 @@ export async function runSetup(options: SetupOptions = {}): Promise<void> {
1552
1552
  }
1553
1553
  }
1554
1554
 
1555
+ /** Set once step 9 writes the repo pin, so the summary names the real file. */
1556
+ let writtenLocalConfigPath: string | null = null;
1557
+
1555
1558
  // Step 9: Save context \u2014 both local (cwd-scoped) and global (user default).
1556
1559
  // Local config only resolves when the server runs with this repo as cwd;
1557
1560
  // remote/OAuth connections and other cwds fall back to the global active
@@ -1562,9 +1565,15 @@ export async function runSetup(options: SetupOptions = {}): Promise<void> {
1562
1565
  const localConfig: { workspaceId?: string; projectId?: string } = {};
1563
1566
  if (selectedWorkspaceId) localConfig.workspaceId = selectedWorkspaceId;
1564
1567
  if (selectedProjectId) localConfig.projectId = selectedProjectId;
1565
- saveLocalConfig(localConfig, cwd);
1568
+ // Print the path `saveLocalConfig` ACTUALLY wrote, not a re-derived guess
1569
+ // (#1082). `getLocalConfigPath` is `<cwd>/.hmy.json` with no upward walk,
1570
+ // while the write targets the directory of the pin already in scope — which
1571
+ // may be an ancestor (#893). Run setup from `packages/foo` in a repo pinned
1572
+ // at its root and the two disagree, so this line reported a file that does
1573
+ // not exist. Returning the path is what removes the second derivation.
1574
+ writtenLocalConfigPath = saveLocalConfig(localConfig, cwd);
1566
1575
  console.log(
1567
- ` ${colors.success("\u2713")} ${colors.dim(formatPath(getLocalConfigPath(cwd), home))} ${colors.dim("(created)")}`,
1576
+ ` ${colors.success("\u2713")} ${colors.dim(formatPath(writtenLocalConfigPath, home))} ${colors.dim("(created)")}`,
1568
1577
  );
1569
1578
 
1570
1579
  // Mirror the choice into the GLOBAL default, explicitly (#893). The local
@@ -1629,7 +1638,7 @@ export async function runSetup(options: SetupOptions = {}): Promise<void> {
1629
1638
  }
1630
1639
  if (selectedWorkspaceId || selectedProjectId) {
1631
1640
  console.log(
1632
- ` Context: ${formatPath(getLocalConfigPath(cwd), home)}`,
1641
+ ` Context: ${formatPath(writtenLocalConfigPath ?? getLocalConfigPath(cwd), home)}`,
1633
1642
  );
1634
1643
  }
1635
1644
 
package/src/tui/writer.ts CHANGED
@@ -33,6 +33,16 @@ function ensureDir(dirPath: string): void {
33
33
  }
34
34
  }
35
35
 
36
+ /**
37
+ * Path fragments that mark a file as living in a Harmony config directory, and
38
+ * therefore as 0o600 rather than 0o644.
39
+ *
40
+ * `.hmy` covers `~/.hmy/agent/config.json` and the repo-local `.hmy.json` in one
41
+ * fragment. `.harmony-mcp` is the pre-#1082 spelling of both, kept so `setup`
42
+ * writing into an un-migrated directory still writes a private file.
43
+ */
44
+ const CONFIG_DIR_MARKERS: readonly string[] = [".hmy", ".harmony-mcp"];
45
+
36
46
  /**
37
47
  * Write a file, optionally skipping if exists
38
48
  */
@@ -49,7 +59,17 @@ export function writeFile(
49
59
 
50
60
  try {
51
61
  ensureDir(dirname(filePath));
52
- const defaultMode = filePath.includes(".harmony-mcp") ? 0o600 : 0o644;
62
+ // 0o600 for anything under a Harmony config directory — that is where the
63
+ // API key lives. BOTH names are tested (#1082): the rename to `~/.hmy`
64
+ // moved where setup writes, so matching the old name alone would have
65
+ // silently written the credential 0o644 (world-readable) at the new path,
66
+ // and nothing about a successful write would have said so. `setup` is also
67
+ // still able to write into a legacy directory that has not been migrated.
68
+ const defaultMode = CONFIG_DIR_MARKERS.some((marker) =>
69
+ filePath.includes(marker),
70
+ )
71
+ ? 0o600
72
+ : 0o644;
53
73
  const mode = options.mode ?? defaultMode;
54
74
  writeFileSync(filePath, content, { mode });
55
75
  if (options.mode !== undefined) {