td-ai-tools 1.2.2 → 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,6 +57,8 @@ 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. Deleting the skill removes unchanged manifest-owned registrations and assets; user-modified hooks and invalid settings files are left untouched with a warning.
61
+
60
62
  `install` now errors when the target item already exists. Use `update` to replace an existing installed skill or agent pack.
61
63
 
62
64
  `delete` removes installed items from both `.claude/` and `.agents/` target directories, and works on any installed skill or agent pack regardless of whether it is in the catalogue.
package/lib/installer.js CHANGED
@@ -9,6 +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 {
13
+ hasProjectHooks,
14
+ installProjectHooks,
15
+ uninstallProjectHooks,
16
+ } from './project-hooks.js';
12
17
 
13
18
  /** @typedef {import('./catalog.js').Ctx} Ctx */
14
19
  /** @typedef {import('./catalog.js').Target} Target */
@@ -66,7 +71,8 @@ export function findSkillSetup(skillDir) {
66
71
  * @returns {boolean}
67
72
  */
68
73
  export function skillHasSetup(ctx, name) {
69
- return Boolean(findSkillSetup(path.join(ctx.skillsDir, name)));
74
+ const skillDir = path.join(ctx.skillsDir, name);
75
+ return Boolean(findSkillSetup(skillDir)) || hasProjectHooks(skillDir);
70
76
  }
71
77
 
72
78
  /**
@@ -75,28 +81,36 @@ export function skillHasSetup(ctx, name) {
75
81
  * @param {string} name - Skill name.
76
82
  * @param {Target} target - The destination layout.
77
83
  * @param {string} dest - Installed skill directory.
84
+ * @param {string} projectRoot - Root of the project receiving the skill.
78
85
  * @param {ReportFn} report
79
86
  * @returns {boolean} Whether setup succeeded or no setup existed.
80
87
  */
81
- function runSkillSetup(name, target, dest, report) {
88
+ function runSkillSetup(name, target, dest, projectRoot, report) {
82
89
  const setup = findSkillSetup(dest);
83
- if (!setup) return true;
84
-
85
- report('step', `setup: ${name} running ${setup.command} ${setup.args.join(' ')} in ${target.root}/skills/${name}/`);
86
- const result = spawnSync(setup.command, setup.args, {
87
- cwd: dest,
88
- stdio: 'inherit',
89
- shell: false,
90
- });
91
- if (result.error) {
92
- report('error', `setup: ${name} failed in ${target.root}/skills/${name}/: ${result.error.message}`);
93
- return false;
90
+ if (setup) {
91
+ report('step', `setup: ${name} running ${setup.command} ${setup.args.join(' ')} in ${target.root}/skills/${name}/`);
92
+ const result = spawnSync(setup.command, setup.args, {
93
+ cwd: dest,
94
+ stdio: 'inherit',
95
+ shell: false,
96
+ });
97
+ if (result.error) {
98
+ report('error', `setup: ${name} failed in ${target.root}/skills/${name}/: ${result.error.message}`);
99
+ return false;
100
+ }
101
+ if (result.status !== 0) {
102
+ report('error', `setup: ${name} failed in ${target.root}/skills/${name}/ with exit code ${result.status}`);
103
+ return false;
104
+ }
105
+ report('success', `setup: ${name} completed in ${target.root}/skills/${name}/`);
94
106
  }
95
- if (result.status !== 0) {
96
- report('error', `setup: ${name} failed in ${target.root}/skills/${name}/ with exit code ${result.status}`);
107
+
108
+ try {
109
+ installProjectHooks(projectRoot, dest, { report });
110
+ } catch (error) {
111
+ report('error', `setup: ${name} hook installation failed: ${error.message}`);
97
112
  return false;
98
113
  }
99
- report('success', `setup: ${name} completed in ${target.root}/skills/${name}/`);
100
114
  return true;
101
115
  }
102
116
 
@@ -150,7 +164,7 @@ export function installSkill(ctx, name, { replaceExisting = false, runSetup = fa
150
164
  copyDir(src, dest);
151
165
  report('success', `skill: ${name} ${replaceExisting ? 'updated' : 'installed'} → ${target.root}/skills/${name}/`);
152
166
  registerBundledAgents(ctx, target, src, report);
153
- if (runSetup && !runSkillSetup(name, target, dest, report)) ok = false;
167
+ if (runSetup && !runSkillSetup(name, target, dest, ctx.targetRoot, report)) ok = false;
154
168
  }
155
169
  return ok;
156
170
  }
@@ -169,6 +183,17 @@ export function installSkill(ctx, name, { replaceExisting = false, runSetup = fa
169
183
  */
170
184
  export function deleteSkill(ctx, name, { report = noop } = {}) {
171
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
+
172
197
  for (const target of ctx.installTargets) {
173
198
  const dest = path.join(ctx.targetRoot, target.root, 'skills', name);
174
199
  const bundledAgents = bundledAgentsIn(path.join(dest, 'agents'));
@@ -0,0 +1,338 @@
1
+ /**
2
+ * @file Declarative, non-destructive project hook installation for skills.
3
+ *
4
+ * Skills opt in by shipping `hooks/manifest.json`. Hook assets are copied from
5
+ * that directory and registrations are merged into runtime JSON settings.
6
+ */
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { isDeepStrictEqual } from 'node:util';
10
+
11
+ const MANIFEST_PATH = path.join('hooks', 'manifest.json');
12
+
13
+ /** @type {(kind: 'success'|'warn'|'info', message: string) => void} */
14
+ const noop = () => {};
15
+
16
+ /**
17
+ * Whether a skill directory contains a project hook manifest.
18
+ *
19
+ * @param {string} skillDir
20
+ * @returns {boolean}
21
+ */
22
+ export function hasProjectHooks(skillDir) {
23
+ return fs.existsSync(path.join(skillDir, MANIFEST_PATH));
24
+ }
25
+
26
+ /**
27
+ * Resolve a manifest path while preventing traversal outside its allowed root.
28
+ *
29
+ * @param {string} root
30
+ * @param {string} relativePath
31
+ * @param {string} label
32
+ * @returns {string}
33
+ */
34
+ function resolveWithin(root, relativePath, label) {
35
+ if (typeof relativePath !== 'string' || !relativePath || path.isAbsolute(relativePath)) {
36
+ throw new Error(`${label} must be a non-empty relative path`);
37
+ }
38
+ const resolvedRoot = path.resolve(root);
39
+ const resolved = path.resolve(resolvedRoot, relativePath);
40
+ const relation = path.relative(resolvedRoot, resolved);
41
+ if (relation === '..' || relation.startsWith(`..${path.sep}`) || path.isAbsolute(relation)) {
42
+ throw new Error(`${label} resolves outside the project root: ${relativePath}`);
43
+ }
44
+ return resolved;
45
+ }
46
+
47
+ /**
48
+ * Merge one runtime hook into a settings object.
49
+ *
50
+ * Command hooks are identified by `type` + `command`. Reinstalling updates the
51
+ * managed hook in place while preserving unrelated settings, event groups,
52
+ * group fields, hook fields, and sibling hooks.
53
+ *
54
+ * @param {Record<string, any>} settings
55
+ * @param {string} event
56
+ * @param {Record<string, any>} hook
57
+ * @param {Record<string, any>} [group]
58
+ * @returns {void}
59
+ */
60
+ export function mergeHookRegistration(settings, event, hook, group = {}) {
61
+ if (!settings || typeof settings !== 'object' || Array.isArray(settings)) {
62
+ throw new Error('hook settings must be a JSON object');
63
+ }
64
+ if (typeof event !== 'string' || !event) {
65
+ throw new Error('hook event must be a non-empty string');
66
+ }
67
+ if (!hook || typeof hook !== 'object' || Array.isArray(hook)
68
+ || typeof hook.type !== 'string' || typeof hook.command !== 'string') {
69
+ throw new Error('hook must define string type and command fields');
70
+ }
71
+ if (!group || typeof group !== 'object' || Array.isArray(group)) {
72
+ throw new Error('hook group must be a JSON object');
73
+ }
74
+
75
+ if (settings.hooks === undefined) settings.hooks = {};
76
+ if (!settings.hooks || typeof settings.hooks !== 'object' || Array.isArray(settings.hooks)) {
77
+ throw new Error('settings.hooks must be a JSON object');
78
+ }
79
+ if (settings.hooks[event] === undefined) settings.hooks[event] = [];
80
+ if (!Array.isArray(settings.hooks[event])) {
81
+ throw new Error(`settings.hooks.${event} must be an array`);
82
+ }
83
+
84
+ for (const existingGroup of settings.hooks[event]) {
85
+ if (!existingGroup || !Array.isArray(existingGroup.hooks)) continue;
86
+ const existingHook = existingGroup.hooks.find(candidate => (
87
+ candidate?.type === hook.type && candidate?.command === hook.command
88
+ ));
89
+ if (!existingHook) continue;
90
+ Object.assign(existingGroup, group);
91
+ Object.assign(existingHook, hook);
92
+ return;
93
+ }
94
+
95
+ settings.hooks[event].push({ ...group, hooks: [{ ...hook }] });
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
+
161
+ /**
162
+ * Read JSON settings, backing up malformed input before starting a clean file.
163
+ *
164
+ * @param {string} settingsPath
165
+ * @param {(kind: 'success'|'warn'|'info', message: string) => void} report
166
+ * @returns {Record<string, any>}
167
+ */
168
+ function readSettings(settingsPath, report) {
169
+ if (!fs.existsSync(settingsPath)) return {};
170
+ try {
171
+ const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
172
+ if (!settings || typeof settings !== 'object' || Array.isArray(settings)) {
173
+ throw new Error('root value is not an object');
174
+ }
175
+ return settings;
176
+ } catch (error) {
177
+ const backupPath = `${settingsPath}.bak`;
178
+ fs.copyFileSync(settingsPath, backupPath);
179
+ report('warn', `hooks: ${settingsPath} was invalid JSON; backed up to ${backupPath}`);
180
+ return {};
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Install the hook assets and registrations declared by a skill.
186
+ *
187
+ * Manifest shape:
188
+ * `{ files: [{ source, destination }], registrations: [{ settings, event,
189
+ * hook, group? }] }`. File sources are relative to `hooks/`; destinations and
190
+ * settings paths are relative to the installing project.
191
+ *
192
+ * @param {string} projectRoot
193
+ * @param {string} skillDir
194
+ * @param {object} [options]
195
+ * @param {(kind: 'success'|'warn'|'info', message: string) => void} [options.report]
196
+ * @returns {boolean} Whether a manifest was found and installed.
197
+ */
198
+ export function installProjectHooks(projectRoot, skillDir, { report = noop } = {}) {
199
+ const manifestPath = path.join(skillDir, MANIFEST_PATH);
200
+ if (!fs.existsSync(manifestPath)) return false;
201
+
202
+ const hooksDir = path.dirname(manifestPath);
203
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
204
+ if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) {
205
+ throw new Error(`hook manifest must be a JSON object: ${manifestPath}`);
206
+ }
207
+
208
+ for (const file of manifest.files || []) {
209
+ const source = resolveWithin(hooksDir, file.source, 'hook source');
210
+ const destination = resolveWithin(projectRoot, file.destination, 'hook destination');
211
+ if (!fs.existsSync(source) || !fs.statSync(source).isFile()) {
212
+ throw new Error(`hook source does not exist: ${source}`);
213
+ }
214
+ fs.mkdirSync(path.dirname(destination), { recursive: true });
215
+ fs.copyFileSync(source, destination);
216
+ fs.chmodSync(destination, fs.statSync(source).mode);
217
+ report('success', `hooks: installed ${file.destination}`);
218
+ }
219
+
220
+ for (const registration of manifest.registrations || []) {
221
+ const settingsPath = resolveWithin(projectRoot, registration.settings, 'hook settings path');
222
+ const settings = readSettings(settingsPath, report);
223
+ mergeHookRegistration(
224
+ settings,
225
+ registration.event,
226
+ registration.hook,
227
+ registration.group || {},
228
+ );
229
+ fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
230
+ fs.writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
231
+ report('success', `hooks: merged ${registration.event} into ${registration.settings}`);
232
+ }
233
+
234
+ return true;
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,10 +1,10 @@
1
1
  {
2
2
  "name": "td-ai-tools",
3
- "version": "1.2.2",
3
+ "version": "1.2.4",
4
4
  "description": "Install agent skills and packs into your project",
5
5
  "type": "module",
6
6
  "scripts": {
7
- "test": "node --test",
7
+ "test": "node --test test/*.test.js",
8
8
  "readmes": "node scripts/sync-readmes.js",
9
9
  "readmes:check": "node scripts/sync-readmes.js --check",
10
10
  "smoke:install": "./scripts/smoke-install.sh"
package/skills/README.md CHANGED
@@ -6,10 +6,8 @@
6
6
  - `basecamp`: Interact with Basecamp via the Basecamp CLI.
7
7
  - `browser-validation`: Before completing a task validate frontend or template changes in a real browser with the Playwright-CLI…
8
8
  - `cache-reset`: Clear and warm Laravel and Statamic caches (including Statamic Glide image caches) after content or template…
9
- - `car-ticket-generator`: Generate a ticket for the codex-auto-runner queue
10
9
  - `client-overview`: Generate a client-facing markdown report that summarizes all changes on the current branch against the…
11
10
  - `debugging-ios-webkit`: Debugs iOS Safari/Chrome-iOS rendering bugs — stale paints, viewport/browser-chrome clipping, mobile-only CSS…
12
- - `everhour-basecamp-estimates`: Bulk update Everhour task estimates from a Basecamp todo or todolist URL, then append bracketed hours to the…
13
11
  - `forge-cli`: Manage Laravel Forge servers, sites, and provisioned resources from the terminal with the Laravel Forge CLI,…
14
12
  - `horizon-component-migration`: Bundle Shopify Horizon components into a migration package for a different theme, including recursive…
15
13
  - `playwright-cli`: Automates browser interactions for web testing, screenshots, and data extraction.
@@ -18,7 +16,7 @@
18
16
  - `pull-request-statamic`: Generates GitHub pull request descriptions for Statamic and Laravel development by analyzing git diffs and…
19
17
  - `record-changes`: Update `docs/changes.md` by summarizing the current branch against the primary development branch.
20
18
  - `shopify-cli`: Shopify CLI workflows for theme development.
21
- - `shopify-lint`: Run Shopify CLI Theme Check with Theory Digital's bundled custom checks while reporting and failing only on offenses in files modified on the current Git branch.
19
+ - `shopify-lint`: Run Shopify CLI Theme Check with Theory Digital's bundled custom checks while reporting and failing only on
22
20
  - `stylesheet-migration`: Migrate Shopify Liquid `{% stylesheet %}` blocks into theme CSS assets using bundled Python scripts.
23
21
  - `td-js-vanilla-rules`: Theory Digital vanilla JavaScript standards for Shopify theme work.
24
22
  - `td-review`: Run parallel code review agents on a PR (including TD theme compliance) and produce a synthesized findings…
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: shopify-lint
3
- version: 1.0.0
3
+ version: 1.1.0
4
4
  description: Run Shopify CLI Theme Check with Theory Digital's bundled custom checks while reporting and failing only on offenses in files modified on the current Git branch. Use when Codex needs to lint a Shopify theme, validate branch-scoped Liquid or theme changes, enforce Theory theme rules, or avoid surfacing pre-existing Theme Check offenses from untouched files.
5
5
  ---
6
6
 
@@ -18,6 +18,9 @@ Installing with `td-ai-tools install --setup shopify-lint` runs this automatical
18
18
 
19
19
  1. Runs `npm install && npm run build` inside `theme-check-theory/`, producing `dist/index.js` — the CommonJS entry point the root `.theme-check.yml` requires.
20
20
  2. Writes a `.theme-check.yml` at the project root wiring in the bundled checks. If one already exists it is left untouched; ensure its `require:` list includes `./.agents/skills/shopify-lint/theme-check-theory`.
21
+ 3. Through the installer's shared project-hook setup, installs matching Claude and Codex Stop hooks and merges their registrations into `.claude/settings.json` and `.codex/hooks.json` without replacing unrelated settings or hooks. Re-running setup updates the Shopify Lint hook in place instead of duplicating it.
22
+
23
+ The Stop hooks run this skill's branch-scoped lint before handoff, return modified-file offenses to the agent, and allow handoff after three unsuccessful fix rounds. They require `jq`, `python3`, and Shopify CLI to be available when the hook runs. Directly running `scripts/setup.sh` performs the package build and Theme Check configuration; use `td-ai-tools install --setup` or `td-ai-tools update --setup` to apply the shared hook manifest as well.
21
24
 
22
25
  Keep these dependencies inside `theme-check-theory/node_modules`; do not install Node dependencies at the theme root.
23
26
 
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env bash
2
+ # Stop hook: run Theory's branch-scoped Shopify Theme Check before the agent
3
+ # hands off. Blocks the stop (feeding offenses back to the agent) when lint
4
+ # finds offenses in files modified on the current branch.
5
+ set -u
6
+
7
+ input=$(cat)
8
+ session_id=$(printf '%s' "$input" | jq -r '.session_id // "unknown"')
9
+
10
+ root="${CLAUDE_PROJECT_DIR:-$PWD}"
11
+ cd "$root" || exit 0
12
+
13
+ # Only lint when this checkout is a Shopify theme with the skill installed.
14
+ lint_script=".agents/skills/shopify-lint/scripts/shopify_lint.py"
15
+ [ -f "$lint_script" ] || exit 0
16
+
17
+ # Cap consecutive blocks so an offense the agent can't fix doesn't loop forever.
18
+ counter_file="${TMPDIR:-/tmp}/claude-shopify-lint-stop-${session_id}.count"
19
+
20
+ output=$(python3 "$lint_script" --path . 2>&1)
21
+ status=$?
22
+
23
+ if [ "$status" -eq 0 ]; then
24
+ rm -f "$counter_file"
25
+ exit 0
26
+ fi
27
+
28
+ if [ "$status" -eq 1 ]; then
29
+ count=0
30
+ [ -f "$counter_file" ] && count=$(cat "$counter_file" 2>/dev/null || echo 0)
31
+ count=$((count + 1))
32
+ printf '%s' "$count" > "$counter_file"
33
+
34
+ if [ "$count" -gt 3 ]; then
35
+ rm -f "$counter_file"
36
+ jq -n --arg msg "Shopify lint still failing after 3 fix rounds — allowing handoff. Remaining offenses:\n$output" \
37
+ '{systemMessage: $msg}'
38
+ exit 0
39
+ fi
40
+
41
+ jq -n --arg reason "Shopify Theme Check found offenses in files modified on this branch. Fix these before finishing (leave offenses in unmodified files alone — they are pre-existing), then stop again and the check will re-run:
42
+
43
+ $output" '{decision: "block", reason: $reason}'
44
+ exit 0
45
+ fi
46
+
47
+ # Exit 2 = Git/CLI/JSON tooling error. Don't block handoff on a broken toolchain.
48
+ jq -n --arg msg "Shopify lint hook could not run (exit $status): $output" '{systemMessage: $msg}'
49
+ exit 0
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env bash
2
+ # Stop hook: run Theory's branch-scoped Shopify Theme Check before the agent
3
+ # hands off. Continues the turn with offenses from files modified on the
4
+ # current branch so Codex can fix them before trying to stop again.
5
+ set -u
6
+
7
+ input=$(cat)
8
+ session_id=$(printf '%s' "$input" | jq -r '.session_id // "unknown"' | tr -cd '[:alnum:]_-')
9
+
10
+ root=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0
11
+ cd "$root" || exit 0
12
+
13
+ # Only lint when this checkout is a Shopify theme with the skill installed.
14
+ lint_script=".agents/skills/shopify-lint/scripts/shopify_lint.py"
15
+ [ -f "$lint_script" ] || exit 0
16
+
17
+ # Cap consecutive continuations so an offense the agent can't fix doesn't loop forever.
18
+ counter_file="${TMPDIR:-/tmp}/codex-shopify-lint-stop-${session_id:-unknown}.count"
19
+
20
+ output=$(python3 "$lint_script" --path . 2>&1)
21
+ status=$?
22
+
23
+ if [ "$status" -eq 0 ]; then
24
+ rm -f "$counter_file"
25
+ exit 0
26
+ fi
27
+
28
+ if [ "$status" -eq 1 ]; then
29
+ count=0
30
+ [ -f "$counter_file" ] && count=$(cat "$counter_file" 2>/dev/null || echo 0)
31
+ count=$((count + 1))
32
+ printf '%s' "$count" > "$counter_file"
33
+
34
+ if [ "$count" -gt 3 ]; then
35
+ rm -f "$counter_file"
36
+ jq -n --arg msg "Shopify lint still failing after 3 fix rounds — allowing handoff. Remaining offenses:\n$output" \
37
+ '{systemMessage: $msg}'
38
+ exit 0
39
+ fi
40
+
41
+ jq -n --arg reason "Shopify Theme Check found offenses in files modified on this branch. Fix these before finishing (leave offenses in unmodified files alone — they are pre-existing), then stop again and the check will re-run:
42
+
43
+ $output" '{continue: false, stopReason: $reason}'
44
+ exit 0
45
+ fi
46
+
47
+ # Exit 2 = Git/CLI/JSON tooling error. Don't continue the turn on a broken toolchain.
48
+ jq -n --arg msg "Shopify lint hook could not run (exit $status): $output" '{systemMessage: $msg}'
49
+ exit 0
@@ -0,0 +1,34 @@
1
+ {
2
+ "files": [
3
+ {
4
+ "source": "claude-stop.sh",
5
+ "destination": ".claude/hooks/shopify-lint-stop.sh"
6
+ },
7
+ {
8
+ "source": "codex-stop.sh",
9
+ "destination": ".codex/hooks/td-shopify-lint-stop.sh"
10
+ }
11
+ ],
12
+ "registrations": [
13
+ {
14
+ "settings": ".claude/settings.json",
15
+ "event": "Stop",
16
+ "hook": {
17
+ "type": "command",
18
+ "command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/shopify-lint-stop.sh\"",
19
+ "timeout": 300,
20
+ "statusMessage": "Running Shopify Theme Check on branch changes..."
21
+ }
22
+ },
23
+ {
24
+ "settings": ".codex/hooks.json",
25
+ "event": "Stop",
26
+ "hook": {
27
+ "type": "command",
28
+ "command": "bash \"$(git rev-parse --show-toplevel)/.codex/hooks/td-shopify-lint-stop.sh\"",
29
+ "timeout": 300,
30
+ "statusMessage": "Running Shopify Theme Check on branch changes..."
31
+ }
32
+ }
33
+ ]
34
+ }
@@ -9,6 +9,8 @@ set -euo pipefail
9
9
  # (its node_modules and dist/ are intentionally not committed).
10
10
  # 2. Writes a `.theme-check.yml` at the project root that wires the bundled
11
11
  # checks into Shopify CLI Theme Check.
12
+ # 3. When run by td-ai-tools, its shared setup stage installs the hook assets
13
+ # declared in hooks/manifest.json and merges their Stop registrations.
12
14
 
13
15
  SKILL_DIR="$(cd "$(dirname "$0")/.." && pwd)"
14
16
  PKG_DIR="$SKILL_DIR/theme-check-theory"