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 +1 -1
- package/lib/installer.js +16 -1
- package/lib/project-hooks.js +167 -0
- package/package.json +1 -1
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 {
|
|
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'));
|
package/lib/project-hooks.js
CHANGED
|
@@ -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
|
+
}
|