@mattstack/rt-client 0.3.0 → 0.4.1

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.
@@ -136,7 +136,7 @@ export const REGISTRY: readonly SettingDef[] = [
136
136
  merge: "deep",
137
137
  repoScoped: true,
138
138
  migrated: true,
139
- description: "Branch-naming templates. rt itself has no readers of this key yet the VS Code extension still reads repos/<repo>/branch-naming.json by repo name, which stays authoritative until the extension ports over. Setting this key stores a value nothing consumes.",
139
+ description: "Branch-naming templates, repoScoped. Read by the VS Code extension (extensions/vscode/rt-context), which lazily imports the legacy repos/<repo>/branch-naming.json into this key on first read; rt itself has no CLI-side reader.",
140
140
  },
141
141
  {
142
142
  key: "rt.variations",
@@ -196,17 +196,14 @@ export const REGISTRY: readonly SettingDef[] = [
196
196
  migrated: true,
197
197
  description: "Age floor in days for the log janitor pruning every surface's rotated log files under ~/.mattstack/rt/logs (default 14). A fresh key, not an ownership-latch port, so a default is fine here.",
198
198
  },
199
-
200
- // --- migrated:false (deferred by ruling) --------------------------------
201
199
  {
202
200
  key: "rt.hooks",
203
201
  type: "object",
204
202
  scopes: ALL_SCOPES,
205
203
  merge: "deep",
206
204
  repoScoped: true,
207
- migrated: false,
208
- legacyFile: "repos/<repo>/hooks.json",
209
- description: "User-defined lifecycle hooks rt runs around commands (pre/post command scripts).",
205
+ migrated: true,
206
+ description: "Per-repo git hook enable/disable state ({enabled, hooks: {<hookName>: boolean}}); ownership-latch port of repos/<repo>/hooks.json, store wins per field once it owns the key — including per-hook-name entries inside the nested hooks map, each defaulting to enabled when absent. The installed git-hook shim still greps repos/<repo>/hooks.json with zero process spawns (a hook fires on every git operation); that file is now a DERIVED CACHE this key writes through, kept current by commands/hooks.ts's regenerateHooksCache at every write seam.",
210
207
  },
211
208
 
212
209
  // --- mattstack (installer-lane) -----------------------------------------
@@ -231,6 +228,15 @@ export const REGISTRY: readonly SettingDef[] = [
231
228
  merge: "replace",
232
229
  description: "Absolute path to the installed mattstack.app bundle, written by the app at launch so rt stops hardcoding ~/Applications.",
233
230
  },
231
+ {
232
+ key: "rt.integrations",
233
+ type: "object",
234
+ scopes: ["user"],
235
+ merge: "deep",
236
+ migrated: true,
237
+ description:
238
+ "User-confirmed integration hosts (forgeHost, switchboardUrl), written only by an explicit `rt setup <id> connect --host` after that host validates a real credential. The one trusted source a credential is ever sent to — mattstack.integrations' team-declared host is shown to the user but never auto-used for a fetch.",
239
+ },
234
240
 
235
241
  // --- claude (installer-lane) --------------------------------------------
236
242
  {
@@ -436,4 +442,35 @@ export const REGISTRY: readonly SettingDef[] = [
436
442
  merge: "deep",
437
443
  description: "gitq checkout-board config: tracked repos, local port, and the herdr workspace it launches into.",
438
444
  },
445
+
446
+ // --- chat (RT-48 Task 7) -------------------------------------------------
447
+ {
448
+ key: "chat.handle",
449
+ type: "string",
450
+ scopes: ["user"],
451
+ merge: "replace",
452
+ description: "Explicit rt chat handle for this developer on this machine; overrides the derived <repo>-<dir> handle when set.",
453
+ },
454
+ {
455
+ key: "chat.humanHandle",
456
+ type: "string",
457
+ scopes: ["user"],
458
+ default: "matt",
459
+ merge: "replace",
460
+ description: "The human's own chat handle, so agents can @-mention them by name.",
461
+ },
462
+ {
463
+ key: "chat.push.provider",
464
+ type: "string",
465
+ scopes: ["user"],
466
+ merge: "replace",
467
+ description: "Push notification provider used to alert the human of chat mentions when away from a terminal.",
468
+ },
469
+ {
470
+ key: "chat.push.target",
471
+ type: "string",
472
+ scopes: ["user"],
473
+ merge: "replace",
474
+ description: "Destination (topic/URL/token) the configured chat.push.provider sends to.",
475
+ },
439
476
  ];
@@ -160,6 +160,49 @@ export function setSetting(key: string, value: unknown, scope: SettingScope, opt
160
160
  );
161
161
  }
162
162
 
163
+ /**
164
+ * Removes `key` from the given scope's store, comment-preserving. The refusal
165
+ * ladder is `setSetting`'s minus the value check (there is no value): unknown
166
+ * key, unmigrated, scope not in `def.scopes`, repoIdentity on a non-repoScoped
167
+ * key, and the team-selection rule when ambiguous. Divergences from set, both
168
+ * because removal has nothing to act on: a store FILE that does not exist is a
169
+ * clean no-op rather than a refusal (an explicit `opts.team` naming a team
170
+ * with no local store included — nothing to remove is success, not an error),
171
+ * and a key not present in the store is a no-op. Returns whether anything was
172
+ * actually removed; the local-only reminder prints only on a real removal.
173
+ */
174
+ export function unsetSetting(key: string, scope: SettingScope, opts: SetSettingOpts = {}): boolean {
175
+ const def = getDef(key);
176
+ if (!def) {
177
+ refuse(`unknown setting "${key}" — not in the settings registry (see \`rt settings list\`)`);
178
+ }
179
+
180
+ if (!isMigrated(def)) {
181
+ refuse(migratedFalseMessage(key, def));
182
+ }
183
+
184
+ if (!def.scopes.includes(scope)) {
185
+ refuse(`"${key}" cannot be unset in the ${scope} store (allowed: ${def.scopes.join(", ")})`);
186
+ }
187
+
188
+ if (opts.repoIdentity !== undefined && def.repoScoped !== true) {
189
+ refuse(`"${key}" is not repo-scoped — omit the repo identity`);
190
+ }
191
+
192
+ const storePath = resolveStorePathForUnset(scope, opts);
193
+ if (storePath === null || !existsSync(storePath)) return false;
194
+
195
+ const jsonPath: JSONPath = opts.repoIdentity !== undefined ? ["repos", opts.repoIdentity, key] : [key];
196
+ const removed = removeFromStore(storePath, jsonPath);
197
+
198
+ if (removed) {
199
+ console.error(
200
+ `rt: removed "${key}" from the local ${scope} store (${storePath}) — this is local only until you commit and push it.`,
201
+ );
202
+ }
203
+ return removed;
204
+ }
205
+
163
206
  function migratedFalseMessage(key: string, def: SettingDef): string {
164
207
  const legacyPart = def.legacyFile ? ` — it is still read from ${def.legacyFile}` : "";
165
208
  return `"${key}" is not writable through the settings resolver yet${legacyPart}`;
@@ -188,6 +231,29 @@ function resolveStorePath(scope: SettingScope, opts: SetSettingOpts): string {
188
231
  return teamSettingsPath(teams[0] as string);
189
232
  }
190
233
 
234
+ /**
235
+ * `resolveStorePath` for removal: same selection rule, but "no store to
236
+ * target" answers null (nothing to remove) instead of refusing — EXCEPT the
237
+ * multiple-teams case, which still refuses: guessing which team's store to
238
+ * edit is banned on the unset side for the same reason as the set side.
239
+ */
240
+ function resolveStorePathForUnset(scope: SettingScope, opts: SetSettingOpts): string | null {
241
+ if (scope === "user") return userSettingsPath();
242
+ if (scope === "machine") return machineSettingsPath();
243
+
244
+ if (opts.team !== undefined) {
245
+ const path = teamSettingsPath(opts.team);
246
+ return existsSync(path) ? path : null;
247
+ }
248
+
249
+ const teams = listTeams();
250
+ if (teams.length === 0) return null;
251
+ if (teams.length > 1) {
252
+ refuse(`multiple local team stores found (${teams.join(", ")}) — pass opts.team to choose one`);
253
+ }
254
+ return teamSettingsPath(teams[0] as string);
255
+ }
256
+
191
257
  /** `// header comment\n{}\n` — see module doc for why the object must be seeded before the first `modify`. */
192
258
  function seedHeader(): string {
193
259
  return `// rt settings — created by \`rt settings set\`. JSONC: comments and trailing commas are fine.\n{}\n`;
@@ -279,6 +345,31 @@ function writeIntoStore(storePath: string, jsonPath: JSONPath, value: unknown, c
279
345
  // a torn write would sit as a corrupt uncommitted file until a human
280
346
  // noticed. The edited TEXT is written as-is, never round-tripped through
281
347
  // JSON.stringify, so comments and formatting survive.
348
+ writeTempThenRename(storePath, finalText);
349
+ }
350
+
351
+ /**
352
+ * Removes `jsonPath` from an existing store file. A key that isn't present
353
+ * yields zero edits from `modify` and the file is left untouched (no write,
354
+ * no mtime churn). Malformed stores refuse exactly as on the set side —
355
+ * `modify`-by-offset against a duplicate-key document is as wrong for
356
+ * removal as it is for writes.
357
+ */
358
+ function removeFromStore(storePath: string, jsonPath: JSONPath): boolean {
359
+ const content = readFileSync(storePath, "utf8");
360
+ if (content.trim() === "") return false;
361
+ assertEditableJsonc(storePath, content);
362
+
363
+ const edits = modify(content, jsonPath, undefined, { formattingOptions: FORMAT });
364
+ if (edits.length === 0) return false;
365
+
366
+ const next = applyEdits(content, edits);
367
+ const finalText = next.endsWith("\n") ? next : `${next}\n`;
368
+ writeTempThenRename(storePath, finalText);
369
+ return true;
370
+ }
371
+
372
+ function writeTempThenRename(storePath: string, finalText: string): void {
282
373
  const tmp = `${storePath}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
283
374
  try {
284
375
  writeFileSync(tmp, finalText);