@synity/bitrix-skills 1.3.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 (77) hide show
  1. package/CHANGELOG.md +169 -0
  2. package/LICENSE +21 -0
  3. package/README.md +83 -0
  4. package/bin/bitrix-skills.js +3 -0
  5. package/dist/cli.js +1510 -0
  6. package/dist/features/bx-task/install.js +111 -0
  7. package/dist/features/task-sync/index.js +1053 -0
  8. package/package.json +69 -0
  9. package/src/features/bx/assets/SKILL.md +34 -0
  10. package/src/features/bx/feature.json +8 -0
  11. package/src/features/bx-calendar/assets/SKILL.md +61 -0
  12. package/src/features/bx-calendar/assets/availability.md +65 -0
  13. package/src/features/bx-calendar/assets/meeting.md +87 -0
  14. package/src/features/bx-calendar/assets/reminder.md +71 -0
  15. package/src/features/bx-calendar/assets/sync.md +70 -0
  16. package/src/features/bx-calendar/feature.json +10 -0
  17. package/src/features/bx-crm/assets/SKILL.md +59 -0
  18. package/src/features/bx-crm/assets/commerce.md +96 -0
  19. package/src/features/bx-crm/assets/onboard.md +127 -0
  20. package/src/features/bx-crm/assets/report.md +98 -0
  21. package/src/features/bx-crm/assets/research.md +71 -0
  22. package/src/features/bx-crm/feature.json +10 -0
  23. package/src/features/bx-task/assets/SKILL.md +148 -0
  24. package/src/features/bx-task/assets/lib/bx-api.sh +39 -0
  25. package/src/features/bx-task/assets/lib/bx-checklist.sh +127 -0
  26. package/src/features/bx-task/assets/lib/bx-resolve-task.sh +41 -0
  27. package/src/features/bx-task/assets/lib/bx-state.sh +131 -0
  28. package/src/features/bx-task/assets/lib/bx-tasks.sh +109 -0
  29. package/src/features/bx-task/assets/references/bootstrap.md +184 -0
  30. package/src/features/bx-task/assets/references/feature.md +97 -0
  31. package/src/features/bx-task/assets/references/init-templates/cli-tool.md +47 -0
  32. package/src/features/bx-task/assets/references/init-templates/generic.md +31 -0
  33. package/src/features/bx-task/assets/references/init-templates/library.md +45 -0
  34. package/src/features/bx-task/assets/references/init-templates/monorepo.md +38 -0
  35. package/src/features/bx-task/assets/references/init-templates/npm-package.md +40 -0
  36. package/src/features/bx-task/assets/references/init-templates/web-app.md +46 -0
  37. package/src/features/bx-task/assets/references/init.md +107 -0
  38. package/src/features/bx-task/assets/references/roadmap.md +93 -0
  39. package/src/features/bx-task/assets/references/summary.md +269 -0
  40. package/src/features/bx-task/assets/references/sync.md +104 -0
  41. package/src/features/bx-task/assets/references/time-log.md +214 -0
  42. package/src/features/bx-task/feature.json +10 -0
  43. package/src/features/bx-task/install.ts +117 -0
  44. package/src/features/task-sync/assets/docs/bitrix-task-reference.md +318 -0
  45. package/src/features/task-sync/assets/docs/bitrix-task-sync.md +254 -0
  46. package/src/features/task-sync/assets/githooks/commit-msg +44 -0
  47. package/src/features/task-sync/assets/githooks/install.sh +15 -0
  48. package/src/features/task-sync/assets/manifest.json +108 -0
  49. package/src/features/task-sync/assets/rules/00-bitrix-task-sync.md +161 -0
  50. package/src/features/task-sync/assets/scripts/bitrix-attach-files.sh +55 -0
  51. package/src/features/task-sync/assets/scripts/bitrix-lib.sh +540 -0
  52. package/src/features/task-sync/assets/scripts/bitrix-render-digest.sh +116 -0
  53. package/src/features/task-sync/assets/scripts/bitrix-session-check.sh +51 -0
  54. package/src/features/task-sync/assets/scripts/bitrix-session-sync.sh +89 -0
  55. package/src/features/task-sync/assets/scripts/bitrix-skill-end.sh +165 -0
  56. package/src/features/task-sync/assets/scripts/bitrix-skill-start.sh +58 -0
  57. package/src/features/task-sync/assets/scripts/lib/bb-formatter.sh +110 -0
  58. package/src/features/task-sync/assets/scripts/lib/bitrix-lib.sh +540 -0
  59. package/src/features/task-sync/assets/scripts/lib/time-helpers.sh +57 -0
  60. package/src/features/task-sync/assets/workflows/bitrix-sync.yml +85 -0
  61. package/src/features/task-sync/commands/install.ts +296 -0
  62. package/src/features/task-sync/commands/uninstall.ts +189 -0
  63. package/src/features/task-sync/commands/update.ts +11 -0
  64. package/src/features/task-sync/commands/verify.ts +141 -0
  65. package/src/features/task-sync/feature.json +12 -0
  66. package/src/features/task-sync/index.ts +121 -0
  67. package/src/features/task-sync/lib/dest-map.ts +96 -0
  68. package/src/features/task-sync/lib/drift-check.ts +47 -0
  69. package/src/features/task-sync/lib/file-ops.ts +36 -0
  70. package/src/features/task-sync/lib/manifest.ts +66 -0
  71. package/src/features/task-sync/lib/project-root.ts +38 -0
  72. package/src/features/task-sync/lib/settings-merge.ts +112 -0
  73. package/src/features/task-sync/lib/skill-refs.ts +106 -0
  74. package/src/features/task-sync/lib/task-id-finder.ts +31 -0
  75. package/src/features/task-sync/lib/token-extractor.ts +52 -0
  76. package/src/features/task-sync/lib/version.ts +36 -0
  77. package/src/features/task-sync/types.ts +40 -0
@@ -0,0 +1,296 @@
1
+ // Install command — copies managed files + merges .claude/settings.json + sets up githooks.
2
+ // Idempotent: re-run is safe. Brownfield-safe: never overwrites user-modified files without --force.
3
+ import { copyFile, chmod } from 'node:fs/promises';
4
+ import { assertNotSymlink } from '../../../lib/fs-safety.js';
5
+ import path from 'node:path';
6
+ import kleur from 'kleur';
7
+ import { execa } from 'execa';
8
+ import type { CliOptions, InstallStats, InstallResult } from '../types.js';
9
+ import { detectProjectRoot, hasGitDir } from '../lib/project-root.js';
10
+ import { loadManifest, resolveAssetsDir } from '../lib/manifest.js';
11
+ import { buildDestMap, type DestEntry } from '../lib/dest-map.js';
12
+ import { fileExists, sha256File, ensureDir } from '../lib/file-ops.js';
13
+ import {
14
+ BITRIX_HOOKS_TEMPLATE,
15
+ loadSettingsFile,
16
+ mergeSettings,
17
+ writeSettingsFile,
18
+ } from '../lib/settings-merge.js';
19
+ import { addProjectRef } from '../lib/skill-refs.js';
20
+
21
+ interface InstallContext {
22
+ cwd: string;
23
+ assetsDir: string;
24
+ opts: CliOptions;
25
+ }
26
+
27
+ export async function installFile(
28
+ entry: DestEntry,
29
+ ctx: InstallContext,
30
+ ): Promise<InstallResult> {
31
+ const srcAbs = path.join(ctx.assetsDir, entry.manifestEntry.src);
32
+ const destAbs = entry.destAbs;
33
+
34
+ await assertNotSymlink(destAbs);
35
+
36
+ if (await fileExists(destAbs)) {
37
+ const destSha = await sha256File(destAbs);
38
+ if (destSha === entry.manifestEntry.sha256) {
39
+ return 'skipped';
40
+ }
41
+ if (!ctx.opts.force) {
42
+ return 'skipped'; // user-modified — preserve
43
+ }
44
+ if (ctx.opts.dryRun) {
45
+ console.log(` ${kleur.yellow('[dry-run]')} overwrite ${entry.destRel}`);
46
+ return 'planned';
47
+ }
48
+ await ensureDir(path.dirname(destAbs));
49
+ await copyFile(srcAbs, destAbs);
50
+ await chmod(destAbs, entry.manifestEntry.mode);
51
+ return 'overwritten';
52
+ }
53
+
54
+ if (ctx.opts.dryRun) {
55
+ console.log(` ${kleur.yellow('[dry-run]')} copy → ${entry.destRel}`);
56
+ return 'planned';
57
+ }
58
+
59
+ await ensureDir(path.dirname(destAbs));
60
+ await copyFile(srcAbs, destAbs);
61
+ await chmod(destAbs, entry.manifestEntry.mode);
62
+ return 'copied';
63
+ }
64
+
65
+ export async function installManifestFiles(
66
+ entries: DestEntry[],
67
+ ctx: InstallContext,
68
+ ): Promise<InstallStats> {
69
+ const stats: InstallStats = {
70
+ copied: 0,
71
+ skipped: 0,
72
+ overwritten: 0,
73
+ planned: 0,
74
+ filtered: 0,
75
+ warnings: [],
76
+ };
77
+
78
+ for (const entry of entries) {
79
+ if (entry.optionalFlag && ctx.opts[entry.optionalFlag]) {
80
+ stats.filtered += 1;
81
+ continue;
82
+ }
83
+ const result = await installFile(entry, ctx);
84
+ if (result === 'skipped') {
85
+ // Differentiate between sha-match-skip vs user-modified-skip via re-check
86
+ const destSha = (await fileExists(entry.destAbs))
87
+ ? await sha256File(entry.destAbs)
88
+ : null;
89
+ if (destSha && destSha !== entry.manifestEntry.sha256 && !ctx.opts.force) {
90
+ stats.warnings.push(`skipped (user-modified): ${entry.destRel}`);
91
+ }
92
+ }
93
+ stats[result] += 1;
94
+ }
95
+ return stats;
96
+ }
97
+
98
+ async function mergeSettingsStep(ctx: InstallContext): Promise<{ added: boolean; alreadyMerged: boolean }> {
99
+ const settingsPath = path.join(ctx.cwd, '.claude/settings.json');
100
+ const existing = await loadSettingsFile(settingsPath);
101
+ const merged = mergeSettings(existing, BITRIX_HOOKS_TEMPLATE);
102
+
103
+ // Detect "already merged" case: existing already deep-equal to merged.
104
+ const alreadyMerged = JSON.stringify(existing) === JSON.stringify(merged);
105
+
106
+ if (ctx.opts.dryRun) {
107
+ if (alreadyMerged) {
108
+ console.log(` ${kleur.yellow('[dry-run]')} settings.json: already up-to-date`);
109
+ } else {
110
+ console.log(` ${kleur.yellow('[dry-run]')} settings.json: would merge 4 Bitrix hooks`);
111
+ }
112
+ return { added: !alreadyMerged, alreadyMerged };
113
+ }
114
+
115
+ if (!alreadyMerged) {
116
+ await writeSettingsFile(settingsPath, merged);
117
+ }
118
+ return { added: !alreadyMerged, alreadyMerged };
119
+ }
120
+
121
+ async function ensureUfTokensTotal(
122
+ cwd: string,
123
+ stats: InstallStats,
124
+ ): Promise<void> {
125
+ const webhookUrl = process.env['BITRIX_WEBHOOK_URL'];
126
+ if (!webhookUrl) return; // no webhook configured — skip silently
127
+
128
+ const libPath = path.join(cwd, '.claude/scripts/bitrix-lib.sh');
129
+ if (!(await fileExists(libPath))) return;
130
+
131
+ // Inline bash: source lib, call task.item.userfield.add, treat ERROR_CORE as success
132
+ const script = `
133
+ set -e
134
+ source "$1"
135
+ result=$(b24_call "task.item.userfield.add" \
136
+ "$(jq -n '{PARAMS:{USER_TYPE_ID:"double",FIELD_NAME:"UF_AI_TOKENS_TOTAL",XML_ID:"UF_AI_TOKENS_TOTAL",LABEL:"AI Tokens (cumulative)",SORT:500,MULTIPLE:"N",MANDATORY:"N",SETTINGS:{DEFAULT_VALUE:0,PRECISION:0}}}')")
137
+ err=$(echo "$result" | jq -r '.error // empty')
138
+ # ERROR_CORE = already exists → treat as success
139
+ if [[ -n "$err" && "$err" != "ERROR_CORE" ]]; then
140
+ echo "warn:$err" >&2
141
+ fi
142
+ exit 0
143
+ `;
144
+
145
+ try {
146
+ const result = await execa('bash', ['-c', script, '--', libPath], {
147
+ cwd,
148
+ reject: false,
149
+ env: { ...process.env },
150
+ });
151
+ if (result.stderr && !result.stderr.includes('ERROR_CORE')) {
152
+ const warnLine = (result.stderr.match(/warn:(.+)/) ?? [])[1];
153
+ if (warnLine) stats.warnings.push(`UF_AI_TOKENS_TOTAL: ${warnLine.trim()}`);
154
+ }
155
+ } catch {
156
+ stats.warnings.push('UF_AI_TOKENS_TOTAL: ensure skipped (bash error)');
157
+ }
158
+ }
159
+
160
+ async function runGitHooksInstall(ctx: InstallContext): Promise<{ run: boolean; warning?: string }> {
161
+ const hooksScript = path.join(ctx.cwd, '.githooks/install.sh');
162
+ if (!(await fileExists(hooksScript))) {
163
+ return { run: false, warning: 'githooks install.sh not found (skipped)' };
164
+ }
165
+ if (!(await hasGitDir(ctx.cwd))) {
166
+ return { run: false, warning: 'no .git/ in project root — skipped githooks setup' };
167
+ }
168
+ if (ctx.opts.dryRun) {
169
+ console.log(` ${kleur.yellow('[dry-run]')} would run: bash .githooks/install.sh`);
170
+ return { run: false };
171
+ }
172
+ try {
173
+ await chmod(hooksScript, 0o755);
174
+ await execa('bash', [hooksScript], { cwd: ctx.cwd, stdio: 'pipe' });
175
+ return { run: true };
176
+ } catch (err) {
177
+ return { run: false, warning: `githooks install failed: ${(err as Error).message}` };
178
+ }
179
+ }
180
+
181
+ export async function run(opts: CliOptions): Promise<number> {
182
+ let cwd: string;
183
+ try {
184
+ cwd = await detectProjectRoot(opts.cwd);
185
+ } catch (err) {
186
+ console.error(kleur.red(`error: ${(err as Error).message}`));
187
+ return 2;
188
+ }
189
+
190
+ const assetsDir = resolveAssetsDir();
191
+ const ctx: InstallContext = { cwd, assetsDir, opts };
192
+
193
+ console.log('');
194
+ console.log(kleur.bold().cyan(' ⚡ bitrix-skills task-sync install'));
195
+ console.log(kleur.gray(` project root: ${cwd}`));
196
+ console.log(kleur.gray(` assets: ${assetsDir}`));
197
+ if (opts.dryRun) console.log(kleur.yellow(' mode: --dry-run (no FS changes)'));
198
+ console.log('');
199
+
200
+ let manifest;
201
+ try {
202
+ manifest = await loadManifest();
203
+ } catch (err) {
204
+ console.error(kleur.red(`error: ${(err as Error).message}`));
205
+ return 1;
206
+ }
207
+
208
+ const dests = buildDestMap(manifest, cwd);
209
+
210
+ // Step 1: copy managed files
211
+ let stats: InstallStats;
212
+ try {
213
+ stats = await installManifestFiles(dests, ctx);
214
+ } catch (err) {
215
+ console.error(kleur.red(`copy error: ${(err as Error).message}`));
216
+ return 1;
217
+ }
218
+
219
+ // Step 2: merge .claude/settings.json
220
+ let settingsResult: { added: boolean; alreadyMerged: boolean };
221
+ try {
222
+ settingsResult = await mergeSettingsStep(ctx);
223
+ } catch (err) {
224
+ console.error(kleur.red(`settings merge error: ${(err as Error).message}`));
225
+ return 3;
226
+ }
227
+
228
+ // Step 2.5: ensure UF_AI_TOKENS_TOTAL user field exists on TASKS_TASK entity
229
+ if (!opts.noSkill && !opts.dryRun) {
230
+ await ensureUfTokensTotal(cwd, stats);
231
+ }
232
+
233
+ // Step 3: run .githooks/install.sh
234
+ const githooksResult = opts.noGithooks
235
+ ? { run: false, warning: '--no-githooks: skipped' }
236
+ : await runGitHooksInstall(ctx);
237
+
238
+ // Step 4: register this project as a referrer of the shared user-scope skill.
239
+ // Skipped under --no-skill (no skill files were copied) and --dry-run.
240
+ let skillRefCount: number | null = null;
241
+ if (!opts.noSkill && !opts.dryRun) {
242
+ try {
243
+ const refs = await addProjectRef(cwd);
244
+ skillRefCount = refs.length;
245
+ } catch (err) {
246
+ stats.warnings.push(`skill ref registry: ${(err as Error).message}`);
247
+ }
248
+ }
249
+
250
+ // Summary
251
+ console.log('');
252
+ if (opts.dryRun) {
253
+ const parts = [
254
+ `${stats.planned} would be copied/overwritten`,
255
+ `${stats.skipped} skipped`,
256
+ stats.filtered > 0 ? `${stats.filtered} filtered (--no-* flags)` : null,
257
+ ].filter(Boolean);
258
+ console.log(kleur.yellow(` [dry-run] ${parts.join(', ')}`));
259
+ } else {
260
+ const parts = [
261
+ `${stats.copied} copied`,
262
+ stats.overwritten > 0 ? `${stats.overwritten} overwritten` : null,
263
+ `${stats.skipped} skipped`,
264
+ stats.filtered > 0 ? `${stats.filtered} filtered (--no-* flags)` : null,
265
+ ].filter(Boolean);
266
+ console.log(kleur.green(` ✓ files: ${parts.join(', ')}`));
267
+ }
268
+ if (settingsResult.alreadyMerged) {
269
+ console.log(kleur.gray(' · settings.json: already up-to-date'));
270
+ } else if (settingsResult.added && !opts.dryRun) {
271
+ console.log(kleur.green(' ✓ settings.json: 4 Bitrix hooks merged'));
272
+ }
273
+ if (githooksResult.run) {
274
+ console.log(kleur.green(' ✓ git hooks: core.hooksPath = .githooks (local)'));
275
+ } else if (githooksResult.warning) {
276
+ console.log(kleur.yellow(` ! ${githooksResult.warning}`));
277
+ }
278
+ if (skillRefCount !== null) {
279
+ const noun = skillRefCount === 1 ? 'project' : 'projects';
280
+ console.log(kleur.gray(` · skill: shared across ${skillRefCount} ${noun}`));
281
+ }
282
+ for (const w of stats.warnings) {
283
+ console.log(kleur.yellow(` ! ${w}`));
284
+ }
285
+
286
+ if (!opts.dryRun) {
287
+ console.log('');
288
+ console.log(kleur.bold(' Next steps:'));
289
+ console.log(' 1. Set TASK_ID in CLAUDE.md (see docs/bitrix-task-sync.md §2)');
290
+ console.log(' 2. Export BITRIX_WEBHOOK_URL in your shell rc');
291
+ console.log(' 3. Run: npx @synity/bitrix-skills verify');
292
+ console.log('');
293
+ }
294
+
295
+ return 0;
296
+ }
@@ -0,0 +1,189 @@
1
+ // Uninstall command — removes managed files (only if sha256 matches manifest), cleans settings.
2
+ // Preserves user-modified files + user's other hooks.
3
+ import { unlink } from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import kleur from 'kleur';
6
+ import { execa } from 'execa';
7
+ import type { CliOptions } from '../types.js';
8
+ import { detectProjectRoot, hasGitDir } from '../lib/project-root.js';
9
+ import { loadManifest } from '../lib/manifest.js';
10
+ import { buildDestMap } from '../lib/dest-map.js';
11
+ import { fileExists, sha256File } from '../lib/file-ops.js';
12
+ import { loadSettingsFile, removeBitrixHooks, writeSettingsFile } from '../lib/settings-merge.js';
13
+ import { clearAllRefs, getRefs, removeProjectRef } from '../lib/skill-refs.js';
14
+
15
+ export async function run(opts: CliOptions): Promise<number> {
16
+ let cwd: string;
17
+ try {
18
+ cwd = await detectProjectRoot(opts.cwd);
19
+ } catch (err) {
20
+ console.error(kleur.red(`error: ${(err as Error).message}`));
21
+ return 2;
22
+ }
23
+
24
+ console.log('');
25
+ console.log(kleur.bold().cyan(' ⚡ bitrix-skills task-sync uninstall'));
26
+ console.log(kleur.gray(` project root: ${cwd}`));
27
+ if (opts.dryRun) console.log(kleur.yellow(' mode: --dry-run (no FS changes)'));
28
+ console.log('');
29
+
30
+ let manifest;
31
+ try {
32
+ manifest = await loadManifest();
33
+ } catch (err) {
34
+ console.error(kleur.red(`error: ${(err as Error).message}`));
35
+ return 1;
36
+ }
37
+
38
+ const dests = buildDestMap(manifest, cwd);
39
+ let removed = 0;
40
+ let preserved = 0;
41
+ let absent = 0;
42
+ let filtered = 0;
43
+ const warnings: string[] = [];
44
+
45
+ // Decide skill-file fate up front based on the ref registry.
46
+ // Skill files at ~/.claude/skills/bitrix-sync-install/ are shared across all
47
+ // Synity projects on this machine. We only delete them when:
48
+ // (a) --remove-skill — user explicitly demands a global wipe, OR
49
+ // (b) this is the LAST registered project AND the registry is non-empty.
50
+ // Empty registry = ambiguous state (skill installed pre-ref-counting?). Treat
51
+ // as "don't know who else uses it" → preserve unless --remove-skill.
52
+ const cwdAbs = path.resolve(cwd);
53
+ const refsBefore = await getRefs();
54
+ const otherProjects = refsBefore.filter((p) => p !== cwdAbs);
55
+ const isLastRegisteredReferrer =
56
+ refsBefore.length > 0 && otherProjects.length === 0;
57
+ const shouldRemoveSkillFiles =
58
+ !opts.noSkill && (opts.removeSkill || isLastRegisteredReferrer);
59
+
60
+ for (const d of dests) {
61
+ if (d.category === 'doc' && opts.keepDocs) {
62
+ filtered += 1;
63
+ continue;
64
+ }
65
+ // User-scope skill is shared across all projects on the machine.
66
+ // Default = preserve unless we're the last referrer or --remove-skill.
67
+ if (d.category === 'skill' && !shouldRemoveSkillFiles) {
68
+ filtered += 1;
69
+ continue;
70
+ }
71
+ // Honor install-time skip flags so uninstall doesn't try to remove
72
+ // files that were never copied.
73
+ if (d.optionalFlag && opts[d.optionalFlag]) {
74
+ filtered += 1;
75
+ continue;
76
+ }
77
+ if (!(await fileExists(d.destAbs))) {
78
+ absent += 1;
79
+ continue;
80
+ }
81
+ const actual = await sha256File(d.destAbs);
82
+ if (actual !== d.manifestEntry.sha256) {
83
+ preserved += 1;
84
+ warnings.push(`preserved (user-modified): ${d.destRel}`);
85
+ continue;
86
+ }
87
+ if (opts.dryRun) {
88
+ console.log(` ${kleur.yellow('[dry-run]')} remove ${d.destRel}`);
89
+ removed += 1;
90
+ continue;
91
+ }
92
+ await unlink(d.destAbs);
93
+ removed += 1;
94
+ }
95
+
96
+ // Settings subtraction
97
+ const settingsPath = path.join(cwd, '.claude/settings.json');
98
+ let settingsChanged = false;
99
+ if (await fileExists(settingsPath)) {
100
+ try {
101
+ const existing = await loadSettingsFile(settingsPath);
102
+ const cleaned = removeBitrixHooks(existing);
103
+ const before = JSON.stringify(existing);
104
+ const after = JSON.stringify(cleaned);
105
+ if (before !== after) {
106
+ settingsChanged = true;
107
+ if (!opts.dryRun) {
108
+ await writeSettingsFile(settingsPath, cleaned);
109
+ }
110
+ }
111
+ } catch (err) {
112
+ warnings.push(`settings.json: ${(err as Error).message}`);
113
+ }
114
+ }
115
+
116
+ // Mutate skill ref registry. Done after the file loop so dry-run never writes.
117
+ // --remove-skill nukes the entire ref list (other projects' refs become orphaned
118
+ // anyway since we deleted shared files). Default just removes this project.
119
+ let skillRefRemaining: number | null = null;
120
+ if (!opts.noSkill && !opts.dryRun) {
121
+ try {
122
+ if (opts.removeSkill) {
123
+ await clearAllRefs();
124
+ skillRefRemaining = 0;
125
+ } else {
126
+ const result = await removeProjectRef(cwdAbs);
127
+ skillRefRemaining = result.remaining;
128
+ }
129
+ } catch (err) {
130
+ warnings.push(`skill ref registry: ${(err as Error).message}`);
131
+ }
132
+ }
133
+
134
+ // Unset core.hooksPath if pointed to .githooks
135
+ let hooksPathUnset = false;
136
+ if (await hasGitDir(cwd)) {
137
+ try {
138
+ const { stdout } = await execa('git', ['config', '--local', '--get', 'core.hooksPath'], {
139
+ cwd,
140
+ reject: false,
141
+ });
142
+ if (stdout.trim() === '.githooks') {
143
+ if (!opts.dryRun) {
144
+ await execa('git', ['config', '--local', '--unset', 'core.hooksPath'], { cwd, reject: false });
145
+ }
146
+ hooksPathUnset = true;
147
+ }
148
+ } catch {
149
+ // non-fatal
150
+ }
151
+ }
152
+
153
+ // Summary
154
+ console.log('');
155
+ const filteredSuffix = filtered > 0 ? `, ${filtered} filtered (--keep-docs / no --remove-skill / --no-*)` : '';
156
+ if (opts.dryRun) {
157
+ console.log(kleur.yellow(` [dry-run] would remove ${removed} files, preserve ${preserved}, ${absent} already absent${filteredSuffix}`));
158
+ } else {
159
+ console.log(kleur.green(` ✓ files: ${removed} removed, ${preserved} preserved, ${absent} already absent${filteredSuffix}`));
160
+ }
161
+ if (settingsChanged) {
162
+ console.log(kleur.green(' ✓ settings.json: Bitrix hooks removed'));
163
+ } else {
164
+ console.log(kleur.gray(' · settings.json: nothing to remove'));
165
+ }
166
+ if (hooksPathUnset) {
167
+ console.log(kleur.green(' ✓ git hooks: core.hooksPath unset'));
168
+ }
169
+ if (skillRefRemaining !== null) {
170
+ if (opts.removeSkill) {
171
+ console.log(kleur.green(' ✓ skill: ~/.claude/skills/bitrix-sync-install removed (--remove-skill)'));
172
+ } else if (skillRefRemaining === 0) {
173
+ console.log(kleur.green(' ✓ skill: last project unlinked, files removed'));
174
+ } else {
175
+ const noun = skillRefRemaining === 1 ? 'project' : 'projects';
176
+ console.log(
177
+ kleur.gray(
178
+ ` · skill: preserved (still used by ${skillRefRemaining} other ${noun}); pass --remove-skill to force`,
179
+ ),
180
+ );
181
+ }
182
+ }
183
+ for (const w of warnings) {
184
+ console.log(kleur.yellow(` ! ${w}`));
185
+ }
186
+ console.log('');
187
+
188
+ return 0;
189
+ }
@@ -0,0 +1,11 @@
1
+ // Update command — force re-install of managed files only (preserves user files outside manifest).
2
+ // Equivalent to install --force, but never touches user-modified files outside the manifest scope.
3
+ import type { CliOptions } from '../types.js';
4
+ import * as install from './install.js';
5
+
6
+ export async function run(opts: CliOptions): Promise<number> {
7
+ // Update = install with force, preserving the user's other settings + files.
8
+ // The install command already only touches manifest-managed files; force makes it
9
+ // overwrite drifted ones.
10
+ return install.run({ ...opts, force: true });
11
+ }
@@ -0,0 +1,141 @@
1
+ // Verify command — drift check + env validation + (optional) live webhook smoke test.
2
+ // Spawns bash to source bitrix-lib.sh + invoke b24_comment, ensuring 1:1 with runtime.
3
+ import path from 'node:path';
4
+ import kleur from 'kleur';
5
+ import { execa } from 'execa';
6
+ import type { CliOptions } from '../types.js';
7
+ import { detectProjectRoot } from '../lib/project-root.js';
8
+ import { loadManifest } from '../lib/manifest.js';
9
+ import { checkDrift } from '../lib/drift-check.js';
10
+ import { findTaskId, isValidTaskId } from '../lib/task-id-finder.js';
11
+ import { fileExists } from '../lib/file-ops.js';
12
+
13
+ // Replace any occurrence of the webhook URL (and tail token segment) with a placeholder.
14
+ function redactWebhook(text: string): string {
15
+ let out = text;
16
+ const url = process.env['BITRIX_WEBHOOK_URL'];
17
+ if (url) {
18
+ out = out.split(url).join('<BITRIX_WEBHOOK_URL>');
19
+ // Also redact bare /rest/<id>/<token>/ patterns in case lib emits parts of the URL.
20
+ out = out.replace(/\/rest\/\d+\/[A-Za-z0-9]+/g, '/rest/<redacted>');
21
+ }
22
+ return out;
23
+ }
24
+
25
+ export async function run(opts: CliOptions): Promise<number> {
26
+ let cwd: string;
27
+ try {
28
+ cwd = await detectProjectRoot(opts.cwd);
29
+ } catch (err) {
30
+ console.error(kleur.red(`error: ${(err as Error).message}`));
31
+ return 2;
32
+ }
33
+
34
+ console.log('');
35
+ console.log(kleur.bold().cyan(' ⚡ bitrix-skills task-sync verify'));
36
+ console.log(kleur.gray(` project root: ${cwd}`));
37
+ console.log('');
38
+
39
+ // 1. Manifest integrity
40
+ let manifest;
41
+ try {
42
+ manifest = await loadManifest();
43
+ } catch (err) {
44
+ console.error(kleur.red(`error: ${(err as Error).message}`));
45
+ return 1;
46
+ }
47
+
48
+ const drift = await checkDrift(manifest, cwd, {
49
+ noGithooks: opts.noGithooks,
50
+ noWorkflow: opts.noWorkflow,
51
+ noSkill: opts.noSkill,
52
+ });
53
+ if (drift.length > 0) {
54
+ console.error(kleur.red(' ✗ drift detected — run `bitrix-skills update` to fix:'));
55
+ for (const d of drift) {
56
+ console.error(` - ${d.path} (${d.reason})`);
57
+ }
58
+ return 4;
59
+ }
60
+ console.log(kleur.green(' ✓ manifest: all managed files match expected sha256'));
61
+
62
+ // 2. Env: TASK_ID
63
+ const taskId = await findTaskId(cwd);
64
+ if (!taskId) {
65
+ console.error(kleur.red(' ✗ TASK_ID not found in any CLAUDE.md (walk-up from project root)'));
66
+ console.error(kleur.gray(' add to root or feature CLAUDE.md:\n ## Bitrix Task\n TASK_ID: 12345'));
67
+ return 1;
68
+ }
69
+ if (!isValidTaskId(taskId)) {
70
+ console.error(kleur.red(` ✗ TASK_ID "${taskId}" is not a valid numeric Bitrix task ID`));
71
+ console.error(kleur.gray(' expected digits only (e.g. TASK_ID: 12345)'));
72
+ return 1;
73
+ }
74
+ console.log(kleur.green(` ✓ TASK_ID found: ${taskId}`));
75
+
76
+ // 3. Env: BITRIX_WEBHOOK_URL
77
+ if (!process.env['BITRIX_WEBHOOK_URL']) {
78
+ console.error(kleur.red(' ✗ BITRIX_WEBHOOK_URL not set in env'));
79
+ console.error(kleur.gray(' export BITRIX_WEBHOOK_URL="https://your.bitrix24.com/rest/USER_ID/TOKEN/"'));
80
+ return 2;
81
+ }
82
+ console.log(kleur.green(' ✓ BITRIX_WEBHOOK_URL is set'));
83
+
84
+ if (opts.quiet) {
85
+ console.log(kleur.gray(' · --quiet: skipping live webhook test'));
86
+ console.log('');
87
+ return 0;
88
+ }
89
+
90
+ // 4. Live smoke test
91
+ const libPath = path.join(cwd, '.claude/scripts/bitrix-lib.sh');
92
+ if (!(await fileExists(libPath))) {
93
+ console.error(kleur.red(` ✗ ${libPath} missing — run install first`));
94
+ return 1;
95
+ }
96
+
97
+ const stamp = new Date().toISOString();
98
+ const message = `🔧 [bitrix-skills verify] ${stamp}`;
99
+ // Pass libPath/taskId/message as positional args ($1/$2/$3) so they are not
100
+ // re-parsed by the shell — prevents injection if any value contains metachars.
101
+ const script = 'set -e; source "$1"; b24_comment "$2" "$3"';
102
+ const result = await execa('bash', ['-c', script, '--', libPath, taskId, message], {
103
+ cwd,
104
+ reject: false,
105
+ });
106
+
107
+ if (result.exitCode !== 0 || !/"result"/.test(result.stdout || '')) {
108
+ console.error(kleur.red(' ✗ webhook call failed'));
109
+ if (result.stderr) console.error(kleur.gray(` stderr: ${redactWebhook(result.stderr).slice(0, 500)}`));
110
+ if (result.stdout) console.error(kleur.gray(` stdout: ${redactWebhook(result.stdout).slice(0, 500)}`));
111
+ return 3;
112
+ }
113
+
114
+ console.log(kleur.green(` ✓ live webhook OK — comment posted to TASK_ID=${taskId}`));
115
+
116
+ // 5. Disk scope probe — file-attach feature degrades gracefully if missing
117
+ const diskScript = `
118
+ set -e
119
+ source "$1"
120
+ result=$(b24_call "disk.storage.getlist" '{}')
121
+ err=$(echo "$result" | jq -r '.error // empty')
122
+ if [[ "$err" == *"insufficient_scope"* || "$err" == *"ACCESS_DENIED"* ]]; then
123
+ echo "missing_scope"
124
+ else
125
+ echo "ok"
126
+ fi
127
+ `;
128
+ const diskResult = await execa('bash', ['-c', diskScript, '--', libPath], {
129
+ cwd,
130
+ reject: false,
131
+ });
132
+ if ((diskResult.stdout || '').trim() === 'missing_scope') {
133
+ console.log(kleur.yellow(' ! disk scope missing — file-attach feature degraded'));
134
+ console.log(kleur.gray(' add "disk" to webhook permissions to enable plan/brainstorm file sync'));
135
+ } else {
136
+ console.log(kleur.green(' ✓ disk scope OK — file-attach feature available'));
137
+ }
138
+
139
+ console.log('');
140
+ return 0;
141
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "task-sync",
3
+ "displayName": "Bitrix Task Sync (hooks)",
4
+ "version": "0.3.0-pre",
5
+ "target": "project",
6
+ "description": "AI session sync to Bitrix task chat via hooks",
7
+ "needs": ["bash-lib"],
8
+ "requires": {
9
+ "env": ["BITRIX_WEBHOOK_URL"],
10
+ "task_id": true
11
+ }
12
+ }