@sentry/warden 0.0.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.
Files changed (199) hide show
  1. package/.agents/skills/find-bugs/SKILL.md +75 -0
  2. package/.agents/skills/vercel-react-best-practices/AGENTS.md +2934 -0
  3. package/.agents/skills/vercel-react-best-practices/SKILL.md +136 -0
  4. package/.agents/skills/vercel-react-best-practices/rules/advanced-event-handler-refs.md +55 -0
  5. package/.agents/skills/vercel-react-best-practices/rules/advanced-init-once.md +42 -0
  6. package/.agents/skills/vercel-react-best-practices/rules/advanced-use-latest.md +39 -0
  7. package/.agents/skills/vercel-react-best-practices/rules/async-api-routes.md +38 -0
  8. package/.agents/skills/vercel-react-best-practices/rules/async-defer-await.md +80 -0
  9. package/.agents/skills/vercel-react-best-practices/rules/async-dependencies.md +51 -0
  10. package/.agents/skills/vercel-react-best-practices/rules/async-parallel.md +28 -0
  11. package/.agents/skills/vercel-react-best-practices/rules/async-suspense-boundaries.md +99 -0
  12. package/.agents/skills/vercel-react-best-practices/rules/bundle-barrel-imports.md +59 -0
  13. package/.agents/skills/vercel-react-best-practices/rules/bundle-conditional.md +31 -0
  14. package/.agents/skills/vercel-react-best-practices/rules/bundle-defer-third-party.md +49 -0
  15. package/.agents/skills/vercel-react-best-practices/rules/bundle-dynamic-imports.md +35 -0
  16. package/.agents/skills/vercel-react-best-practices/rules/bundle-preload.md +50 -0
  17. package/.agents/skills/vercel-react-best-practices/rules/client-event-listeners.md +74 -0
  18. package/.agents/skills/vercel-react-best-practices/rules/client-localstorage-schema.md +71 -0
  19. package/.agents/skills/vercel-react-best-practices/rules/client-passive-event-listeners.md +48 -0
  20. package/.agents/skills/vercel-react-best-practices/rules/client-swr-dedup.md +56 -0
  21. package/.agents/skills/vercel-react-best-practices/rules/js-batch-dom-css.md +107 -0
  22. package/.agents/skills/vercel-react-best-practices/rules/js-cache-function-results.md +80 -0
  23. package/.agents/skills/vercel-react-best-practices/rules/js-cache-property-access.md +28 -0
  24. package/.agents/skills/vercel-react-best-practices/rules/js-cache-storage.md +70 -0
  25. package/.agents/skills/vercel-react-best-practices/rules/js-combine-iterations.md +32 -0
  26. package/.agents/skills/vercel-react-best-practices/rules/js-early-exit.md +50 -0
  27. package/.agents/skills/vercel-react-best-practices/rules/js-hoist-regexp.md +45 -0
  28. package/.agents/skills/vercel-react-best-practices/rules/js-index-maps.md +37 -0
  29. package/.agents/skills/vercel-react-best-practices/rules/js-length-check-first.md +49 -0
  30. package/.agents/skills/vercel-react-best-practices/rules/js-min-max-loop.md +82 -0
  31. package/.agents/skills/vercel-react-best-practices/rules/js-set-map-lookups.md +24 -0
  32. package/.agents/skills/vercel-react-best-practices/rules/js-tosorted-immutable.md +57 -0
  33. package/.agents/skills/vercel-react-best-practices/rules/rendering-activity.md +26 -0
  34. package/.agents/skills/vercel-react-best-practices/rules/rendering-animate-svg-wrapper.md +47 -0
  35. package/.agents/skills/vercel-react-best-practices/rules/rendering-conditional-render.md +40 -0
  36. package/.agents/skills/vercel-react-best-practices/rules/rendering-content-visibility.md +38 -0
  37. package/.agents/skills/vercel-react-best-practices/rules/rendering-hoist-jsx.md +46 -0
  38. package/.agents/skills/vercel-react-best-practices/rules/rendering-hydration-no-flicker.md +82 -0
  39. package/.agents/skills/vercel-react-best-practices/rules/rendering-hydration-suppress-warning.md +30 -0
  40. package/.agents/skills/vercel-react-best-practices/rules/rendering-svg-precision.md +28 -0
  41. package/.agents/skills/vercel-react-best-practices/rules/rendering-usetransition-loading.md +75 -0
  42. package/.agents/skills/vercel-react-best-practices/rules/rerender-defer-reads.md +39 -0
  43. package/.agents/skills/vercel-react-best-practices/rules/rerender-dependencies.md +45 -0
  44. package/.agents/skills/vercel-react-best-practices/rules/rerender-derived-state-no-effect.md +40 -0
  45. package/.agents/skills/vercel-react-best-practices/rules/rerender-derived-state.md +29 -0
  46. package/.agents/skills/vercel-react-best-practices/rules/rerender-functional-setstate.md +74 -0
  47. package/.agents/skills/vercel-react-best-practices/rules/rerender-lazy-state-init.md +58 -0
  48. package/.agents/skills/vercel-react-best-practices/rules/rerender-memo-with-default-value.md +38 -0
  49. package/.agents/skills/vercel-react-best-practices/rules/rerender-memo.md +44 -0
  50. package/.agents/skills/vercel-react-best-practices/rules/rerender-move-effect-to-event.md +45 -0
  51. package/.agents/skills/vercel-react-best-practices/rules/rerender-simple-expression-in-memo.md +35 -0
  52. package/.agents/skills/vercel-react-best-practices/rules/rerender-transitions.md +40 -0
  53. package/.agents/skills/vercel-react-best-practices/rules/rerender-use-ref-transient-values.md +73 -0
  54. package/.agents/skills/vercel-react-best-practices/rules/server-after-nonblocking.md +73 -0
  55. package/.agents/skills/vercel-react-best-practices/rules/server-auth-actions.md +96 -0
  56. package/.agents/skills/vercel-react-best-practices/rules/server-cache-lru.md +41 -0
  57. package/.agents/skills/vercel-react-best-practices/rules/server-cache-react.md +76 -0
  58. package/.agents/skills/vercel-react-best-practices/rules/server-dedup-props.md +65 -0
  59. package/.agents/skills/vercel-react-best-practices/rules/server-parallel-fetching.md +83 -0
  60. package/.agents/skills/vercel-react-best-practices/rules/server-serialization.md +38 -0
  61. package/.claude/settings.json +57 -0
  62. package/.claude/settings.local.json +88 -0
  63. package/.claude/skills/agent-prompt/SKILL.md +54 -0
  64. package/.claude/skills/agent-prompt/references/agentic-patterns.md +94 -0
  65. package/.claude/skills/agent-prompt/references/anti-patterns.md +140 -0
  66. package/.claude/skills/agent-prompt/references/context-design.md +124 -0
  67. package/.claude/skills/agent-prompt/references/core-principles.md +75 -0
  68. package/.claude/skills/agent-prompt/references/model-guidance.md +118 -0
  69. package/.claude/skills/agent-prompt/references/output-formats.md +98 -0
  70. package/.claude/skills/agent-prompt/references/skill-structure.md +115 -0
  71. package/.claude/skills/agent-prompt/references/system-prompts.md +115 -0
  72. package/.claude/skills/notseer/SKILL.md +131 -0
  73. package/.claude/skills/skill-writer/SKILL.md +140 -0
  74. package/.claude/skills/testing-guidelines/SKILL.md +132 -0
  75. package/.claude/skills/warden-skill/SKILL.md +250 -0
  76. package/.claude/skills/warden-skill/references/config-schema.md +133 -0
  77. package/.dex/config.toml +2 -0
  78. package/.github/workflows/ci.yml +33 -0
  79. package/.github/workflows/release.yml +54 -0
  80. package/.github/workflows/warden.yml +40 -0
  81. package/AGENTS.md +89 -0
  82. package/CONTRIBUTING.md +60 -0
  83. package/LICENSE +105 -0
  84. package/README.md +43 -0
  85. package/SPEC.md +263 -0
  86. package/action.yml +87 -0
  87. package/assets/favicon.png +0 -0
  88. package/assets/warden-icon-bw.svg +5 -0
  89. package/assets/warden-icon-purple.png +0 -0
  90. package/assets/warden-icon-purple.svg +5 -0
  91. package/docs/.claude/settings.local.json +11 -0
  92. package/docs/astro.config.mjs +43 -0
  93. package/docs/package.json +19 -0
  94. package/docs/pnpm-lock.yaml +4000 -0
  95. package/docs/public/favicon.svg +5 -0
  96. package/docs/src/components/Code.astro +141 -0
  97. package/docs/src/components/PackageManagerTabs.astro +183 -0
  98. package/docs/src/components/Terminal.astro +212 -0
  99. package/docs/src/layouts/Base.astro +380 -0
  100. package/docs/src/pages/cli.astro +167 -0
  101. package/docs/src/pages/config.astro +394 -0
  102. package/docs/src/pages/guide.astro +449 -0
  103. package/docs/src/pages/index.astro +490 -0
  104. package/docs/src/styles/global.css +551 -0
  105. package/docs/tsconfig.json +3 -0
  106. package/docs/vercel.json +5 -0
  107. package/eslint.config.js +33 -0
  108. package/package.json +73 -0
  109. package/src/action/index.ts +1 -0
  110. package/src/action/main.ts +868 -0
  111. package/src/cli/args.test.ts +477 -0
  112. package/src/cli/args.ts +415 -0
  113. package/src/cli/commands/add.ts +447 -0
  114. package/src/cli/commands/init.test.ts +136 -0
  115. package/src/cli/commands/init.ts +132 -0
  116. package/src/cli/commands/setup-app/browser.ts +38 -0
  117. package/src/cli/commands/setup-app/credentials.ts +45 -0
  118. package/src/cli/commands/setup-app/manifest.ts +48 -0
  119. package/src/cli/commands/setup-app/server.ts +172 -0
  120. package/src/cli/commands/setup-app.ts +156 -0
  121. package/src/cli/commands/sync.ts +114 -0
  122. package/src/cli/context.ts +131 -0
  123. package/src/cli/files.test.ts +155 -0
  124. package/src/cli/files.ts +89 -0
  125. package/src/cli/fix.test.ts +310 -0
  126. package/src/cli/fix.ts +387 -0
  127. package/src/cli/git.test.ts +119 -0
  128. package/src/cli/git.ts +318 -0
  129. package/src/cli/index.ts +14 -0
  130. package/src/cli/main.ts +672 -0
  131. package/src/cli/output/box.ts +235 -0
  132. package/src/cli/output/formatters.test.ts +187 -0
  133. package/src/cli/output/formatters.ts +269 -0
  134. package/src/cli/output/icons.ts +13 -0
  135. package/src/cli/output/index.ts +44 -0
  136. package/src/cli/output/ink-runner.tsx +337 -0
  137. package/src/cli/output/jsonl.test.ts +347 -0
  138. package/src/cli/output/jsonl.ts +126 -0
  139. package/src/cli/output/reporter.ts +435 -0
  140. package/src/cli/output/tasks.ts +374 -0
  141. package/src/cli/output/tty.test.ts +117 -0
  142. package/src/cli/output/tty.ts +60 -0
  143. package/src/cli/output/verbosity.test.ts +40 -0
  144. package/src/cli/output/verbosity.ts +31 -0
  145. package/src/cli/terminal.test.ts +148 -0
  146. package/src/cli/terminal.ts +301 -0
  147. package/src/config/index.ts +3 -0
  148. package/src/config/loader.test.ts +313 -0
  149. package/src/config/loader.ts +103 -0
  150. package/src/config/schema.ts +168 -0
  151. package/src/config/writer.test.ts +119 -0
  152. package/src/config/writer.ts +84 -0
  153. package/src/diff/classify.test.ts +162 -0
  154. package/src/diff/classify.ts +92 -0
  155. package/src/diff/coalesce.test.ts +208 -0
  156. package/src/diff/coalesce.ts +133 -0
  157. package/src/diff/context.test.ts +226 -0
  158. package/src/diff/context.ts +201 -0
  159. package/src/diff/index.ts +4 -0
  160. package/src/diff/parser.test.ts +212 -0
  161. package/src/diff/parser.ts +149 -0
  162. package/src/event/context.ts +132 -0
  163. package/src/event/index.ts +2 -0
  164. package/src/event/schedule-context.ts +101 -0
  165. package/src/examples/examples.integration.test.ts +66 -0
  166. package/src/examples/index.test.ts +101 -0
  167. package/src/examples/index.ts +122 -0
  168. package/src/examples/setup.ts +25 -0
  169. package/src/index.ts +115 -0
  170. package/src/output/dedup.test.ts +419 -0
  171. package/src/output/dedup.ts +607 -0
  172. package/src/output/github-checks.test.ts +300 -0
  173. package/src/output/github-checks.ts +476 -0
  174. package/src/output/github-issues.ts +329 -0
  175. package/src/output/index.ts +5 -0
  176. package/src/output/issue-renderer.ts +197 -0
  177. package/src/output/renderer.test.ts +727 -0
  178. package/src/output/renderer.ts +217 -0
  179. package/src/output/stale.test.ts +375 -0
  180. package/src/output/stale.ts +155 -0
  181. package/src/output/types.ts +34 -0
  182. package/src/sdk/index.ts +1 -0
  183. package/src/sdk/runner.test.ts +806 -0
  184. package/src/sdk/runner.ts +1232 -0
  185. package/src/skills/index.ts +36 -0
  186. package/src/skills/loader.test.ts +300 -0
  187. package/src/skills/loader.ts +423 -0
  188. package/src/skills/remote.test.ts +704 -0
  189. package/src/skills/remote.ts +604 -0
  190. package/src/triggers/matcher.test.ts +277 -0
  191. package/src/triggers/matcher.ts +152 -0
  192. package/src/types/index.ts +194 -0
  193. package/src/utils/async.ts +18 -0
  194. package/src/utils/index.test.ts +84 -0
  195. package/src/utils/index.ts +50 -0
  196. package/tsconfig.json +25 -0
  197. package/vitest.config.ts +8 -0
  198. package/vitest.integration.config.ts +11 -0
  199. package/warden.toml +19 -0
@@ -0,0 +1,447 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { join, relative } from 'node:path';
3
+ import chalk from 'chalk';
4
+ import select from '@inquirer/select';
5
+ import { getRepoRoot } from '../git.js';
6
+ import { loadWardenConfig, appendTrigger } from '../../config/index.js';
7
+ import type { Trigger } from '../../config/schema.js';
8
+ import { discoverAllSkills, type DiscoveredSkill } from '../../skills/loader.js';
9
+ import {
10
+ fetchRemote,
11
+ discoverRemoteSkills,
12
+ parseRemoteRef,
13
+ type DiscoveredRemoteSkill,
14
+ } from '../../skills/remote.js';
15
+ import type { Reporter } from '../output/reporter.js';
16
+ import type { CLIOptions } from '../args.js';
17
+ import { ICON_CHECK } from '../output/icons.js';
18
+
19
+ /** Custom theme for select prompts - white for selected, gray for unselected */
20
+ const selectTheme = {
21
+ prefix: {
22
+ idle: '',
23
+ done: '',
24
+ },
25
+ icon: {
26
+ cursor: chalk.white('›'),
27
+ },
28
+ style: {
29
+ message: () => '', // We print heading separately
30
+ highlight: (text: string) => chalk.white(text),
31
+ disabled: (text: string) => chalk.dim(text),
32
+ description: (text: string) => chalk.white(text),
33
+ keysHelpTip: (keys: [key: string, action: string][]) => {
34
+ const keyStr = keys.map(([key, action]) => `${key} ${action}`).join(', ');
35
+ return `\n${chalk.dim(keyStr)}`;
36
+ },
37
+ },
38
+ };
39
+
40
+ /**
41
+ * Render the list of available local skills.
42
+ */
43
+ function renderSkillList(
44
+ skills: Map<string, DiscoveredSkill>,
45
+ configuredSkills: Set<string>,
46
+ reporter: Reporter
47
+ ): void {
48
+ reporter.bold('Available Skills');
49
+ reporter.blank();
50
+
51
+ // Sort skills alphabetically by name
52
+ const sortedSkills = Array.from(skills.entries()).sort((a, b) => a[0].localeCompare(b[0]));
53
+
54
+ for (const [name, discovered] of sortedSkills) {
55
+ const isConfigured = configuredSkills.has(name);
56
+ const dirTag = chalk.dim(`[${discovered.directory}]`);
57
+ const configuredTag = isConfigured ? chalk.dim(' (already configured)') : '';
58
+
59
+ if (reporter.mode.isTTY) {
60
+ const icon = isConfigured ? chalk.dim(ICON_CHECK) : ' ';
61
+ reporter.text(` ${icon} ${chalk.bold(name)} ${dirTag}${configuredTag}`);
62
+ reporter.text(` ${chalk.dim(discovered.skill.description)}`);
63
+ } else {
64
+ const status = isConfigured ? '[configured]' : '';
65
+ reporter.text(`${name} ${status} [${discovered.directory}]`);
66
+ reporter.text(` ${discovered.skill.description}`);
67
+ }
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Render the list of available remote skills.
73
+ */
74
+ function renderRemoteSkillList(
75
+ skills: DiscoveredRemoteSkill[],
76
+ configuredSkills: Set<string>,
77
+ remote: string,
78
+ reporter: Reporter
79
+ ): void {
80
+ reporter.bold(`Available Skills from ${remote}`);
81
+ reporter.blank();
82
+
83
+ // Sort skills alphabetically by name
84
+ const sortedSkills = [...skills].sort((a, b) => a.name.localeCompare(b.name));
85
+
86
+ for (const skill of sortedSkills) {
87
+ const isConfigured = configuredSkills.has(skill.name);
88
+ const configuredTag = isConfigured ? chalk.dim(' (already configured)') : '';
89
+
90
+ if (reporter.mode.isTTY) {
91
+ const icon = isConfigured ? chalk.dim(ICON_CHECK) : ' ';
92
+ reporter.text(` ${icon} ${chalk.bold(skill.name)}${configuredTag}`);
93
+ reporter.text(` ${chalk.dim(skill.description)}`);
94
+ } else {
95
+ const status = isConfigured ? '[configured]' : '';
96
+ reporter.text(`${skill.name} ${status}`);
97
+ reporter.text(` ${skill.description}`);
98
+ }
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Prompt user to select a local skill interactively.
104
+ */
105
+ async function promptSkillSelection(
106
+ skills: Map<string, DiscoveredSkill>,
107
+ configuredSkills: Set<string>,
108
+ reporter: Reporter,
109
+ ): Promise<string | null> {
110
+ // Filter out already configured skills for selection
111
+ const availableSkills = Array.from(skills.entries())
112
+ .filter(([name]) => !configuredSkills.has(name))
113
+ .sort((a, b) => a[0].localeCompare(b[0]));
114
+
115
+ if (availableSkills.length === 0) {
116
+ reporter.warning('All available skills are already configured.');
117
+ return null;
118
+ }
119
+
120
+ const choices = availableSkills.map(([name, discovered]) => {
121
+ return {
122
+ name: `${name} ${chalk.dim(`[${discovered.directory}]`)}`,
123
+ value: name,
124
+ description: discovered.skill.description,
125
+ };
126
+ });
127
+
128
+ reporter.bold('ADD SKILL');
129
+ reporter.blank();
130
+
131
+ try {
132
+ const answer = await select({
133
+ message: '',
134
+ choices,
135
+ theme: selectTheme,
136
+ });
137
+ // Clear the inquirer "done" line
138
+ process.stderr.write('\x1b[1A\x1b[2K');
139
+ return answer;
140
+ } catch {
141
+ // User cancelled (Ctrl+C or escape)
142
+ return null;
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Prompt user to select a remote skill interactively.
148
+ */
149
+ async function promptRemoteSkillSelection(
150
+ skills: DiscoveredRemoteSkill[],
151
+ configuredSkills: Set<string>,
152
+ reporter: Reporter,
153
+ ): Promise<string | null> {
154
+ // Filter out already configured skills for selection
155
+ const availableSkills = skills
156
+ .filter((s) => !configuredSkills.has(s.name))
157
+ .sort((a, b) => a.name.localeCompare(b.name));
158
+
159
+ if (availableSkills.length === 0) {
160
+ reporter.warning('All available skills are already configured.');
161
+ return null;
162
+ }
163
+
164
+ const choices = availableSkills.map((skill) => {
165
+ return {
166
+ name: skill.name,
167
+ value: skill.name,
168
+ description: skill.description,
169
+ };
170
+ });
171
+
172
+ reporter.bold('ADD REMOTE SKILL');
173
+ reporter.blank();
174
+
175
+ try {
176
+ const answer = await select({
177
+ message: '',
178
+ choices,
179
+ theme: selectTheme,
180
+ });
181
+ // Clear the inquirer "done" line
182
+ process.stderr.write('\x1b[1A\x1b[2K');
183
+ return answer;
184
+ } catch {
185
+ // User cancelled (Ctrl+C or escape)
186
+ return null;
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Create a default trigger for a local skill.
192
+ */
193
+ function createDefaultTrigger(skillName: string): Trigger {
194
+ return {
195
+ name: skillName,
196
+ event: 'pull_request',
197
+ actions: ['opened', 'synchronize', 'reopened'],
198
+ skill: skillName,
199
+ };
200
+ }
201
+
202
+ /**
203
+ * Create a trigger for a remote skill.
204
+ */
205
+ function createRemoteTrigger(skillName: string, remote: string): Trigger {
206
+ return {
207
+ name: skillName,
208
+ event: 'pull_request',
209
+ actions: ['opened', 'synchronize', 'reopened'],
210
+ skill: skillName,
211
+ remote,
212
+ };
213
+ }
214
+
215
+ /**
216
+ * Run the add command for remote skills.
217
+ */
218
+ async function runAddRemote(
219
+ options: CLIOptions,
220
+ reporter: Reporter,
221
+ configPath: string,
222
+ configuredSkills: Set<string>
223
+ ): Promise<number> {
224
+ const remote = options.remote ?? '';
225
+ if (!remote) {
226
+ reporter.error('Remote repository is required');
227
+ return 1;
228
+ }
229
+ const cwd = process.cwd();
230
+
231
+ // Validate remote ref format
232
+ try {
233
+ parseRemoteRef(remote);
234
+ } catch (err) {
235
+ const message = err instanceof Error ? err.message : String(err);
236
+ reporter.error(message);
237
+ return 1;
238
+ }
239
+
240
+ // Fetch remote repository
241
+ reporter.step(`Fetching skills from ${remote}...`);
242
+ try {
243
+ await fetchRemote(remote, {
244
+ onProgress: (msg) => reporter.debug(msg),
245
+ });
246
+ } catch (err) {
247
+ const message = err instanceof Error ? err.message : String(err);
248
+ reporter.error(`Failed to fetch remote: ${message}`);
249
+ return 1;
250
+ }
251
+
252
+ // Discover skills in the remote
253
+ const remoteSkills = await discoverRemoteSkills(remote);
254
+
255
+ if (remoteSkills.length === 0) {
256
+ reporter.error(`No skills found in remote: ${remote}`);
257
+ return 1;
258
+ }
259
+
260
+ reporter.success(`Found ${remoteSkills.length} skill${remoteSkills.length === 1 ? '' : 's'}`);
261
+
262
+ // Handle --list: display remote skills and exit
263
+ if (options.list) {
264
+ reporter.blank();
265
+ renderRemoteSkillList(remoteSkills, configuredSkills, remote, reporter);
266
+ return 0;
267
+ }
268
+
269
+ // Get skill to add (from --skill or interactive prompt)
270
+ let skillName: string | null;
271
+
272
+ if (options.skill) {
273
+ skillName = options.skill;
274
+ } else if (reporter.mode.isTTY) {
275
+ reporter.blank();
276
+ skillName = await promptRemoteSkillSelection(remoteSkills, configuredSkills, reporter);
277
+ if (!skillName) {
278
+ return 0; // User quit or no skills available
279
+ }
280
+ } else {
281
+ reporter.error('Skill name required when not running interactively.');
282
+ reporter.tip(`Use: warden add --remote ${remote} --skill <name>`);
283
+ return 1;
284
+ }
285
+
286
+ // Validate skill exists in remote
287
+ const skill = remoteSkills.find((s) => s.name === skillName);
288
+ if (!skill) {
289
+ reporter.error(`Skill '${skillName}' not found in remote: ${remote}`);
290
+ reporter.blank();
291
+ reporter.tip('Available skills:');
292
+ for (const s of remoteSkills) {
293
+ reporter.text(` - ${s.name}`);
294
+ }
295
+ return 1;
296
+ }
297
+
298
+ // Check for duplicate trigger
299
+ if (configuredSkills.has(skillName)) {
300
+ reporter.warning(`Trigger '${skillName}' already exists in warden.toml`);
301
+ reporter.skipped(relative(cwd, configPath), 'trigger already configured');
302
+ return 0;
303
+ }
304
+
305
+ // Append trigger to warden.toml
306
+ const trigger = createRemoteTrigger(skillName, remote);
307
+ try {
308
+ appendTrigger(configPath, trigger);
309
+ reporter.success(`Added trigger '${skillName}' to ${relative(cwd, configPath)}`);
310
+ } catch (err) {
311
+ const message = err instanceof Error ? err.message : String(err);
312
+ reporter.error(`Failed to update warden.toml: ${message}`);
313
+ return 1;
314
+ }
315
+
316
+ // Show success message
317
+ reporter.blank();
318
+ reporter.text(`The trigger will run on pull requests using skill from ${chalk.cyan(remote)}.`);
319
+ reporter.text(`Edit ${chalk.cyan('warden.toml')} to customize filters and output options.`);
320
+
321
+ return 0;
322
+ }
323
+
324
+ /**
325
+ * Run the add command.
326
+ */
327
+ export async function runAdd(options: CLIOptions, reporter: Reporter): Promise<number> {
328
+ const cwd = process.cwd();
329
+
330
+ // 1. Check git repo
331
+ let repoRoot: string;
332
+ try {
333
+ repoRoot = getRepoRoot(cwd);
334
+ } catch {
335
+ reporter.error('Not a git repository. Run this command from a git repository.');
336
+ return 1;
337
+ }
338
+
339
+ // 2. Check warden.toml exists (deferred for --list without --remote)
340
+ const configPath = join(repoRoot, 'warden.toml');
341
+ const hasConfig = existsSync(configPath);
342
+
343
+ // 3. Load existing config if available
344
+ let configuredSkills = new Set<string>();
345
+ if (hasConfig) {
346
+ try {
347
+ const config = loadWardenConfig(repoRoot);
348
+ configuredSkills = new Set(config.triggers.map((t) => t.name));
349
+ } catch (err) {
350
+ const message = err instanceof Error ? err.message : String(err);
351
+ reporter.error(`Failed to load warden.toml: ${message}`);
352
+ return 1;
353
+ }
354
+ }
355
+
356
+ // 4. Handle remote skills with --remote flag
357
+ if (options.remote) {
358
+ // For remote skills, require warden.toml (unless --list)
359
+ if (!hasConfig && !options.list) {
360
+ reporter.error('warden.toml not found.');
361
+ reporter.tip('Run `warden init` first to create the configuration file.');
362
+ return 1;
363
+ }
364
+
365
+ return runAddRemote(options, reporter, configPath, configuredSkills);
366
+ }
367
+
368
+ // 5. Discover local skills
369
+ const skills = await discoverAllSkills(repoRoot, {
370
+ onWarning: (message) => reporter.warning(message),
371
+ });
372
+
373
+ if (skills.size === 0) {
374
+ reporter.error('No skills found.');
375
+ reporter.tip('Add skills to .warden/skills/, .agents/skills/, or .claude/skills/');
376
+ reporter.tip('Or use --remote to add remote skills: warden add --remote owner/repo --skill name');
377
+ return 1;
378
+ }
379
+
380
+ // 6. Handle --list: display skills and exit (works without warden.toml)
381
+ if (options.list) {
382
+ renderSkillList(skills, configuredSkills, reporter);
383
+ return 0;
384
+ }
385
+
386
+ // 7. For adding skills, require warden.toml
387
+ if (!hasConfig) {
388
+ reporter.error('warden.toml not found.');
389
+ reporter.tip('Run `warden init` first to create the configuration file.');
390
+ return 1;
391
+ }
392
+
393
+ // 8. Get skill to add (from arg or interactive prompt)
394
+ let skillName: string | null;
395
+
396
+ if (options.skill) {
397
+ // Non-interactive: skill specified as argument
398
+ skillName = options.skill;
399
+ } else if (reporter.mode.isTTY) {
400
+ // Interactive mode
401
+ skillName = await promptSkillSelection(skills, configuredSkills, reporter);
402
+ if (!skillName) {
403
+ return 0; // User quit or no skills available
404
+ }
405
+ } else {
406
+ // Non-TTY and no skill specified
407
+ reporter.error('Skill name required when not running interactively.');
408
+ reporter.tip('Use: warden add <skill-name> or warden add --list');
409
+ return 1;
410
+ }
411
+
412
+ // 9. Validate skill exists
413
+ if (!skills.has(skillName)) {
414
+ reporter.error(`Skill not found: ${skillName}`);
415
+ reporter.blank();
416
+ reporter.tip('Available skills:');
417
+ for (const name of skills.keys()) {
418
+ reporter.text(` - ${name}`);
419
+ }
420
+ return 1;
421
+ }
422
+
423
+ // 10. Check for duplicate trigger
424
+ if (configuredSkills.has(skillName)) {
425
+ reporter.warning(`Trigger '${skillName}' already exists in warden.toml`);
426
+ reporter.skipped(relative(cwd, configPath), 'trigger already configured');
427
+ return 0;
428
+ }
429
+
430
+ // 11. Append trigger to warden.toml
431
+ const trigger = createDefaultTrigger(skillName);
432
+ try {
433
+ appendTrigger(configPath, trigger);
434
+ reporter.success(`Added trigger '${skillName}' to ${relative(cwd, configPath)}`);
435
+ } catch (err) {
436
+ const message = err instanceof Error ? err.message : String(err);
437
+ reporter.error(`Failed to update warden.toml: ${message}`);
438
+ return 1;
439
+ }
440
+
441
+ // 12. Show success message with next steps
442
+ reporter.blank();
443
+ reporter.text(`The trigger will run on pull requests.`);
444
+ reporter.text(`Edit ${chalk.cyan('warden.toml')} to customize filters and output options.`);
445
+
446
+ return 0;
447
+ }
@@ -0,0 +1,136 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { mkdirSync, rmSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+ import { execSync } from 'node:child_process';
6
+ import { runInit } from './init.js';
7
+ import { Reporter } from '../output/reporter.js';
8
+ import { detectOutputMode } from '../output/tty.js';
9
+ import { Verbosity } from '../output/verbosity.js';
10
+ import type { CLIOptions } from '../args.js';
11
+
12
+ function createMockReporter(): Reporter {
13
+ return new Reporter(detectOutputMode(false), Verbosity.Normal);
14
+ }
15
+
16
+ function createOptions(overrides: Partial<CLIOptions> = {}): CLIOptions {
17
+ return {
18
+ json: false,
19
+ help: false,
20
+ quiet: false,
21
+ verbose: 0,
22
+ fix: false,
23
+ force: false,
24
+ list: false,
25
+ git: false,
26
+ offline: false,
27
+ ...overrides,
28
+ };
29
+ }
30
+
31
+ describe('init command', () => {
32
+ let tempDir: string;
33
+ let originalCwd: string;
34
+
35
+ beforeEach(() => {
36
+ tempDir = join(tmpdir(), `warden-init-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
37
+ mkdirSync(tempDir, { recursive: true });
38
+ originalCwd = process.cwd();
39
+ process.chdir(tempDir);
40
+ // Initialize git repo for tests
41
+ execSync('git init', { cwd: tempDir, stdio: 'ignore' });
42
+ });
43
+
44
+ afterEach(() => {
45
+ process.chdir(originalCwd);
46
+ rmSync(tempDir, { recursive: true, force: true });
47
+ });
48
+
49
+ describe('file creation', () => {
50
+ it('creates warden.toml and workflow in fresh repo', async () => {
51
+ const reporter = createMockReporter();
52
+ const exitCode = await runInit(createOptions(), reporter);
53
+
54
+ expect(exitCode).toBe(0);
55
+ expect(existsSync(join(tempDir, 'warden.toml'))).toBe(true);
56
+ expect(existsSync(join(tempDir, '.github', 'workflows', 'warden.yml'))).toBe(true);
57
+ });
58
+
59
+ it('creates warden.toml with correct content', async () => {
60
+ const reporter = createMockReporter();
61
+ await runInit(createOptions(), reporter);
62
+
63
+ const content = readFileSync(join(tempDir, 'warden.toml'), 'utf-8');
64
+ expect(content).toContain('version = 1');
65
+ });
66
+
67
+ it('creates workflow with correct content', async () => {
68
+ const reporter = createMockReporter();
69
+ await runInit(createOptions(), reporter);
70
+
71
+ const content = readFileSync(join(tempDir, '.github', 'workflows', 'warden.yml'), 'utf-8');
72
+ expect(content).toContain('name: Warden');
73
+ expect(content).toContain('pull_request');
74
+ expect(content).toContain('permissions:');
75
+ expect(content).toContain('pull-requests: write');
76
+ expect(content).toContain('checks: write');
77
+ expect(content).toContain('WARDEN_ANTHROPIC_API_KEY');
78
+ expect(content).toContain('getsentry/warden@v1');
79
+ });
80
+ });
81
+
82
+ describe('existing files', () => {
83
+ it('skips existing files without --force', async () => {
84
+ // Create existing files
85
+ writeFileSync(join(tempDir, 'warden.toml'), 'existing content');
86
+ mkdirSync(join(tempDir, '.github', 'workflows'), { recursive: true });
87
+ writeFileSync(join(tempDir, '.github', 'workflows', 'warden.yml'), 'existing workflow');
88
+
89
+ const reporter = createMockReporter();
90
+ await runInit(createOptions(), reporter);
91
+
92
+ // Files should not be overwritten
93
+ expect(readFileSync(join(tempDir, 'warden.toml'), 'utf-8')).toBe('existing content');
94
+ expect(readFileSync(join(tempDir, '.github', 'workflows', 'warden.yml'), 'utf-8')).toBe('existing workflow');
95
+ });
96
+
97
+ it('overwrites existing files with --force', async () => {
98
+ // Create existing files
99
+ writeFileSync(join(tempDir, 'warden.toml'), 'existing content');
100
+ mkdirSync(join(tempDir, '.github', 'workflows'), { recursive: true });
101
+ writeFileSync(join(tempDir, '.github', 'workflows', 'warden.yml'), 'existing workflow');
102
+
103
+ const reporter = createMockReporter();
104
+ await runInit(createOptions({ force: true }), reporter);
105
+
106
+ // Files should be overwritten with new content
107
+ const tomlContent = readFileSync(join(tempDir, 'warden.toml'), 'utf-8');
108
+ expect(tomlContent).toContain('version = 1');
109
+ expect(tomlContent).not.toBe('existing content');
110
+
111
+ const workflowContent = readFileSync(join(tempDir, '.github', 'workflows', 'warden.yml'), 'utf-8');
112
+ expect(workflowContent).toContain('name: Warden');
113
+ expect(workflowContent).not.toBe('existing workflow');
114
+ });
115
+ });
116
+
117
+ describe('error handling', () => {
118
+ it('fails when not in a git repository', async () => {
119
+ // Create a non-git directory
120
+ const nonGitDir = join(tmpdir(), `warden-non-git-${Date.now()}`);
121
+ mkdirSync(nonGitDir, { recursive: true });
122
+ process.chdir(nonGitDir);
123
+
124
+ try {
125
+ const reporter = createMockReporter();
126
+ const exitCode = await runInit(createOptions(), reporter);
127
+
128
+ expect(exitCode).toBe(1);
129
+ expect(existsSync(join(nonGitDir, 'warden.toml'))).toBe(false);
130
+ } finally {
131
+ process.chdir(originalCwd);
132
+ rmSync(nonGitDir, { recursive: true, force: true });
133
+ }
134
+ });
135
+ });
136
+ });
@@ -0,0 +1,132 @@
1
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
2
+ import { join, relative } from 'node:path';
3
+ import chalk from 'chalk';
4
+ import { getRepoRoot, getGitHubRepoUrl } from '../git.js';
5
+ import type { Reporter } from '../output/reporter.js';
6
+ import type { CLIOptions } from '../args.js';
7
+
8
+ /**
9
+ * Template for warden.toml configuration file.
10
+ */
11
+ function generateWardenToml(): string {
12
+ return `version = 1
13
+ `;
14
+ }
15
+
16
+ /**
17
+ * Template for GitHub Actions workflow file.
18
+ */
19
+ function generateWorkflowYaml(): string {
20
+ return `name: Warden
21
+
22
+ on:
23
+ pull_request:
24
+ types: [opened, synchronize, reopened]
25
+
26
+ permissions:
27
+ contents: read
28
+ pull-requests: write
29
+ checks: write
30
+
31
+ jobs:
32
+ review:
33
+ runs-on: ubuntu-latest
34
+ steps:
35
+ - uses: actions/checkout@v4
36
+ - uses: getsentry/warden@v1
37
+ with:
38
+ anthropic-api-key: \${{ secrets.WARDEN_ANTHROPIC_API_KEY }}
39
+ `;
40
+ }
41
+
42
+ /**
43
+ * Check for existing warden configuration files.
44
+ */
45
+ function checkExistingFiles(repoRoot: string): {
46
+ hasWardenToml: boolean;
47
+ hasWorkflow: boolean;
48
+ } {
49
+ const wardenTomlPath = join(repoRoot, 'warden.toml');
50
+ const workflowPath = join(repoRoot, '.github', 'workflows', 'warden.yml');
51
+
52
+ return {
53
+ hasWardenToml: existsSync(wardenTomlPath),
54
+ hasWorkflow: existsSync(workflowPath),
55
+ };
56
+ }
57
+
58
+ export interface InitOptions {
59
+ force: boolean;
60
+ }
61
+
62
+ /**
63
+ * Run the init command to scaffold warden configuration.
64
+ */
65
+ export async function runInit(options: CLIOptions, reporter: Reporter): Promise<number> {
66
+ const cwd = process.cwd();
67
+
68
+ // Find repo root
69
+ let repoRoot: string;
70
+ try {
71
+ repoRoot = getRepoRoot(cwd);
72
+ } catch {
73
+ reporter.error('Not a git repository. Run this command from a git repository.');
74
+ return 1;
75
+ }
76
+
77
+ // Check for existing files
78
+ const existing = checkExistingFiles(repoRoot);
79
+
80
+ let filesCreated = 0;
81
+
82
+ // Create warden.toml
83
+ const wardenTomlPath = join(repoRoot, 'warden.toml');
84
+ if (existing.hasWardenToml && !options.force) {
85
+ reporter.skipped(relative(cwd, wardenTomlPath), 'already exists');
86
+ } else {
87
+ const content = generateWardenToml();
88
+ writeFileSync(wardenTomlPath, content, 'utf-8');
89
+ reporter.created(relative(cwd, wardenTomlPath));
90
+ filesCreated++;
91
+ }
92
+
93
+ // Create .github/workflows directory if needed
94
+ const workflowDir = join(repoRoot, '.github', 'workflows');
95
+ if (!existsSync(workflowDir)) {
96
+ mkdirSync(workflowDir, { recursive: true });
97
+ }
98
+
99
+ // Create workflow file
100
+ const workflowPath = join(workflowDir, 'warden.yml');
101
+ if (existing.hasWorkflow && !options.force) {
102
+ reporter.skipped(relative(cwd, workflowPath), 'already exists');
103
+ } else {
104
+ const content = generateWorkflowYaml();
105
+ writeFileSync(workflowPath, content, 'utf-8');
106
+ reporter.created(relative(cwd, workflowPath));
107
+ filesCreated++;
108
+ }
109
+
110
+ if (filesCreated === 0) {
111
+ reporter.blank();
112
+ reporter.tip('All configuration files already exist. Use --force to overwrite.');
113
+ return 0;
114
+ }
115
+
116
+ // Print next steps
117
+ reporter.blank();
118
+ reporter.bold('Next steps:');
119
+ reporter.text(` 1. Add a skill: ${chalk.cyan('warden add <skill-name>')}`);
120
+ reporter.text(` 2. Set ${chalk.cyan('WARDEN_ANTHROPIC_API_KEY')} in .env.local`);
121
+ reporter.text(` 3. Add ${chalk.cyan('WARDEN_ANTHROPIC_API_KEY')} to repository secrets`);
122
+
123
+ // Show GitHub secrets URL if available
124
+ const githubUrl = getGitHubRepoUrl(repoRoot);
125
+ if (githubUrl) {
126
+ reporter.text(` ${chalk.dim(githubUrl + '/settings/secrets/actions')}`);
127
+ }
128
+
129
+ reporter.text(' 4. Commit and open a PR to test');
130
+
131
+ return 0;
132
+ }