td-ai-tools 1.2.3 → 1.2.4

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
@@ -57,7 +57,7 @@ This keeps the installed assets available to both Claude-style and `.agents`-sty
57
57
 
58
58
  Some skills provide a recognized setup command (`setup.sh`, `scripts/setup.sh`, or `package.json` with `scripts.setup`). Non-interactive installs and updates run setup only when you pass `--setup`; interactive installs/updates ask for confirmation only when the selected skills include a recognized setup command. Accepted setup runs once inside each installed copy: `.claude/skills/<name>/` and `.agents/skills/<name>/`.
59
59
 
60
- Skills can also declare reusable project-hook setup in `hooks/manifest.json`. The shared installer in `lib/project-hooks.js` copies listed hook assets and merges each registration into its Claude or Codex JSON settings file, preserving unrelated configuration and updating an existing hook with the same `type` and `command` instead of duplicating it. A hook manifest counts as a recognized setup even when the skill has no setup script, so future skills only need to provide their assets and declarative registrations.
60
+ Skills can also declare reusable project-hook setup in `hooks/manifest.json`. The shared installer in `lib/project-hooks.js` copies listed hook assets and merges each registration into its Claude or Codex JSON settings file, preserving unrelated configuration and updating an existing hook with the same `type` and `command` instead of duplicating it. A hook manifest counts as a recognized setup even when the skill has no setup script, so future skills only need to provide their assets and declarative registrations. Deleting the skill removes unchanged manifest-owned registrations and assets; user-modified hooks and invalid settings files are left untouched with a warning.
61
61
 
62
62
  `install` now errors when the target item already exists. Use `update` to replace an existing installed skill or agent pack.
63
63
 
package/lib/installer.js CHANGED
@@ -9,7 +9,11 @@ import path from 'node:path';
9
9
  import { spawnSync } from 'node:child_process';
10
10
  import { copyDir } from './fs-utils.js';
11
11
  import { bundledAgentsIn } from './catalog.js';
12
- import { hasProjectHooks, installProjectHooks } from './project-hooks.js';
12
+ import {
13
+ hasProjectHooks,
14
+ installProjectHooks,
15
+ uninstallProjectHooks,
16
+ } from './project-hooks.js';
13
17
 
14
18
  /** @typedef {import('./catalog.js').Ctx} Ctx */
15
19
  /** @typedef {import('./catalog.js').Target} Target */
@@ -179,6 +183,17 @@ export function installSkill(ctx, name, { replaceExisting = false, runSetup = fa
179
183
  */
180
184
  export function deleteSkill(ctx, name, { report = noop } = {}) {
181
185
  let deletedAny = false;
186
+ const hookSource = ctx.installTargets
187
+ .map(target => path.join(ctx.targetRoot, target.root, 'skills', name))
188
+ .find(hasProjectHooks);
189
+ if (hookSource) {
190
+ try {
191
+ uninstallProjectHooks(ctx.targetRoot, hookSource, { report });
192
+ } catch (error) {
193
+ report('error', `skill: ${name} hook cleanup failed: ${error.message}`);
194
+ }
195
+ }
196
+
182
197
  for (const target of ctx.installTargets) {
183
198
  const dest = path.join(ctx.targetRoot, target.root, 'skills', name);
184
199
  const bundledAgents = bundledAgentsIn(path.join(dest, 'agents'));
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import fs from 'node:fs';
8
8
  import path from 'node:path';
9
+ import { isDeepStrictEqual } from 'node:util';
9
10
 
10
11
  const MANIFEST_PATH = path.join('hooks', 'manifest.json');
11
12
 
@@ -94,6 +95,69 @@ export function mergeHookRegistration(settings, event, hook, group = {}) {
94
95
  settings.hooks[event].push({ ...group, hooks: [{ ...hook }] });
95
96
  }
96
97
 
98
+ /**
99
+ * Remove one unchanged, manifest-owned runtime hook group from settings.
100
+ *
101
+ * The complete group must still equal the manifest declaration. This avoids
102
+ * deleting a hook or group that a user customized after setup.
103
+ *
104
+ * @param {Record<string, any>} settings
105
+ * @param {string} event
106
+ * @param {Record<string, any>} hook
107
+ * @param {Record<string, any>} [group]
108
+ * @returns {boolean} Whether an exact registration was removed.
109
+ */
110
+ export function removeHookRegistration(settings, event, hook, group = {}) {
111
+ if (!settings || typeof settings !== 'object' || Array.isArray(settings)) {
112
+ throw new Error('hook settings must be a JSON object');
113
+ }
114
+ if (typeof event !== 'string' || !event) {
115
+ throw new Error('hook event must be a non-empty string');
116
+ }
117
+ if (!hook || typeof hook !== 'object' || Array.isArray(hook)
118
+ || typeof hook.type !== 'string' || typeof hook.command !== 'string') {
119
+ throw new Error('hook must define string type and command fields');
120
+ }
121
+ if (!group || typeof group !== 'object' || Array.isArray(group)) {
122
+ throw new Error('hook group must be a JSON object');
123
+ }
124
+ if (settings.hooks === undefined) return false;
125
+ if (!settings.hooks || typeof settings.hooks !== 'object' || Array.isArray(settings.hooks)) {
126
+ throw new Error('settings.hooks must be a JSON object');
127
+ }
128
+ if (settings.hooks[event] === undefined) return false;
129
+ if (!Array.isArray(settings.hooks[event])) {
130
+ throw new Error(`settings.hooks.${event} must be an array`);
131
+ }
132
+
133
+ const expectedGroup = { ...group, hooks: [{ ...hook }] };
134
+ const index = settings.hooks[event].findIndex(candidate => (
135
+ isDeepStrictEqual(candidate, expectedGroup)
136
+ ));
137
+ if (index === -1) return false;
138
+ settings.hooks[event].splice(index, 1);
139
+ return true;
140
+ }
141
+
142
+ /**
143
+ * Whether settings still contain a hook with the manifest hook's identity.
144
+ *
145
+ * @param {Record<string, any>} settings
146
+ * @param {string} event
147
+ * @param {Record<string, any>} hook
148
+ * @returns {boolean}
149
+ */
150
+ function hasHookIdentity(settings, event, hook) {
151
+ const groups = settings.hooks?.[event];
152
+ if (!Array.isArray(groups)) return false;
153
+ return groups.some(existingGroup => (
154
+ Array.isArray(existingGroup?.hooks)
155
+ && existingGroup.hooks.some(candidate => (
156
+ candidate?.type === hook.type && candidate?.command === hook.command
157
+ ))
158
+ ));
159
+ }
160
+
97
161
  /**
98
162
  * Read JSON settings, backing up malformed input before starting a clean file.
99
163
  *
@@ -169,3 +233,106 @@ export function installProjectHooks(projectRoot, skillDir, { report = noop } = {
169
233
 
170
234
  return true;
171
235
  }
236
+
237
+ /**
238
+ * Remove unchanged hook assets and registrations declared by a skill.
239
+ *
240
+ * User-modified files, registrations, and malformed settings files are left
241
+ * untouched with a warning. Settings keys and directories are retained even
242
+ * when their managed entries are removed so unrelated structure is preserved.
243
+ *
244
+ * @param {string} projectRoot
245
+ * @param {string} skillDir
246
+ * @param {object} [options]
247
+ * @param {(kind: 'success'|'warn'|'info', message: string) => void} [options.report]
248
+ * @returns {boolean} Whether a manifest was found and processed.
249
+ */
250
+ export function uninstallProjectHooks(projectRoot, skillDir, { report = noop } = {}) {
251
+ const manifestPath = path.join(skillDir, MANIFEST_PATH);
252
+ if (!fs.existsSync(manifestPath)) return false;
253
+
254
+ const hooksDir = path.dirname(manifestPath);
255
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
256
+ if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) {
257
+ throw new Error(`hook manifest must be a JSON object: ${manifestPath}`);
258
+ }
259
+
260
+ const files = (manifest.files || []).map(file => {
261
+ const source = resolveWithin(hooksDir, file.source, 'hook source');
262
+ const destination = resolveWithin(projectRoot, file.destination, 'hook destination');
263
+ if (!fs.existsSync(source) || !fs.statSync(source).isFile()) {
264
+ throw new Error(`hook source does not exist: ${source}`);
265
+ }
266
+ const exists = fs.existsSync(destination);
267
+ const unchanged = exists
268
+ && fs.statSync(destination).isFile()
269
+ && fs.readFileSync(destination).equals(fs.readFileSync(source));
270
+ return { file, destination, exists, unchanged };
271
+ });
272
+ const preserveRegistrations = files.some(file => file.exists && !file.unchanged);
273
+ let preserveHookFiles = preserveRegistrations;
274
+
275
+ for (const registration of manifest.registrations || []) {
276
+ const settingsPath = resolveWithin(projectRoot, registration.settings, 'hook settings path');
277
+ if (!fs.existsSync(settingsPath)) continue;
278
+
279
+ let settings;
280
+ try {
281
+ settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
282
+ if (!settings || typeof settings !== 'object' || Array.isArray(settings)) {
283
+ throw new Error('root value is not an object');
284
+ }
285
+ } catch {
286
+ report('warn', `hooks: ${registration.settings} is invalid JSON; leaving it unchanged`);
287
+ preserveHookFiles = true;
288
+ continue;
289
+ }
290
+
291
+ if (preserveRegistrations
292
+ && hasHookIdentity(settings, registration.event, registration.hook)) {
293
+ report('warn',
294
+ `hooks: leaving ${registration.event} registration in ${registration.settings} because a hook file was modified`);
295
+ preserveHookFiles = true;
296
+ continue;
297
+ }
298
+
299
+ let removed = false;
300
+ try {
301
+ removed = removeHookRegistration(
302
+ settings,
303
+ registration.event,
304
+ registration.hook,
305
+ registration.group || {},
306
+ );
307
+ } catch (error) {
308
+ report('warn', `hooks: could not safely inspect ${registration.settings}: ${error.message}`);
309
+ preserveHookFiles = true;
310
+ continue;
311
+ }
312
+
313
+ if (removed) {
314
+ fs.writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
315
+ report('success', `hooks: removed ${registration.event} from ${registration.settings}`);
316
+ } else if (hasHookIdentity(settings, registration.event, registration.hook)) {
317
+ report('warn',
318
+ `hooks: leaving modified ${registration.event} registration in ${registration.settings}`);
319
+ preserveHookFiles = true;
320
+ }
321
+ }
322
+
323
+ for (const { file, destination, exists, unchanged } of files) {
324
+ if (!exists) continue;
325
+ if (!unchanged) {
326
+ report('warn', `hooks: leaving modified hook file ${file.destination}`);
327
+ continue;
328
+ }
329
+ if (preserveHookFiles) {
330
+ report('warn', `hooks: leaving hook file ${file.destination} because a registration was preserved`);
331
+ continue;
332
+ }
333
+ fs.rmSync(destination);
334
+ report('success', `hooks: removed ${file.destination}`);
335
+ }
336
+
337
+ return true;
338
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "td-ai-tools",
3
- "version": "1.2.3",
3
+ "version": "1.2.4",
4
4
  "description": "Install agent skills and packs into your project",
5
5
  "type": "module",
6
6
  "scripts": {