@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.
- package/CHANGELOG.md +169 -0
- package/LICENSE +21 -0
- package/README.md +83 -0
- package/bin/bitrix-skills.js +3 -0
- package/dist/cli.js +1510 -0
- package/dist/features/bx-task/install.js +111 -0
- package/dist/features/task-sync/index.js +1053 -0
- package/package.json +69 -0
- package/src/features/bx/assets/SKILL.md +34 -0
- package/src/features/bx/feature.json +8 -0
- package/src/features/bx-calendar/assets/SKILL.md +61 -0
- package/src/features/bx-calendar/assets/availability.md +65 -0
- package/src/features/bx-calendar/assets/meeting.md +87 -0
- package/src/features/bx-calendar/assets/reminder.md +71 -0
- package/src/features/bx-calendar/assets/sync.md +70 -0
- package/src/features/bx-calendar/feature.json +10 -0
- package/src/features/bx-crm/assets/SKILL.md +59 -0
- package/src/features/bx-crm/assets/commerce.md +96 -0
- package/src/features/bx-crm/assets/onboard.md +127 -0
- package/src/features/bx-crm/assets/report.md +98 -0
- package/src/features/bx-crm/assets/research.md +71 -0
- package/src/features/bx-crm/feature.json +10 -0
- package/src/features/bx-task/assets/SKILL.md +148 -0
- package/src/features/bx-task/assets/lib/bx-api.sh +39 -0
- package/src/features/bx-task/assets/lib/bx-checklist.sh +127 -0
- package/src/features/bx-task/assets/lib/bx-resolve-task.sh +41 -0
- package/src/features/bx-task/assets/lib/bx-state.sh +131 -0
- package/src/features/bx-task/assets/lib/bx-tasks.sh +109 -0
- package/src/features/bx-task/assets/references/bootstrap.md +184 -0
- package/src/features/bx-task/assets/references/feature.md +97 -0
- package/src/features/bx-task/assets/references/init-templates/cli-tool.md +47 -0
- package/src/features/bx-task/assets/references/init-templates/generic.md +31 -0
- package/src/features/bx-task/assets/references/init-templates/library.md +45 -0
- package/src/features/bx-task/assets/references/init-templates/monorepo.md +38 -0
- package/src/features/bx-task/assets/references/init-templates/npm-package.md +40 -0
- package/src/features/bx-task/assets/references/init-templates/web-app.md +46 -0
- package/src/features/bx-task/assets/references/init.md +107 -0
- package/src/features/bx-task/assets/references/roadmap.md +93 -0
- package/src/features/bx-task/assets/references/summary.md +269 -0
- package/src/features/bx-task/assets/references/sync.md +104 -0
- package/src/features/bx-task/assets/references/time-log.md +214 -0
- package/src/features/bx-task/feature.json +10 -0
- package/src/features/bx-task/install.ts +117 -0
- package/src/features/task-sync/assets/docs/bitrix-task-reference.md +318 -0
- package/src/features/task-sync/assets/docs/bitrix-task-sync.md +254 -0
- package/src/features/task-sync/assets/githooks/commit-msg +44 -0
- package/src/features/task-sync/assets/githooks/install.sh +15 -0
- package/src/features/task-sync/assets/manifest.json +108 -0
- package/src/features/task-sync/assets/rules/00-bitrix-task-sync.md +161 -0
- package/src/features/task-sync/assets/scripts/bitrix-attach-files.sh +55 -0
- package/src/features/task-sync/assets/scripts/bitrix-lib.sh +540 -0
- package/src/features/task-sync/assets/scripts/bitrix-render-digest.sh +116 -0
- package/src/features/task-sync/assets/scripts/bitrix-session-check.sh +51 -0
- package/src/features/task-sync/assets/scripts/bitrix-session-sync.sh +89 -0
- package/src/features/task-sync/assets/scripts/bitrix-skill-end.sh +165 -0
- package/src/features/task-sync/assets/scripts/bitrix-skill-start.sh +58 -0
- package/src/features/task-sync/assets/scripts/lib/bb-formatter.sh +110 -0
- package/src/features/task-sync/assets/scripts/lib/bitrix-lib.sh +540 -0
- package/src/features/task-sync/assets/scripts/lib/time-helpers.sh +57 -0
- package/src/features/task-sync/assets/workflows/bitrix-sync.yml +85 -0
- package/src/features/task-sync/commands/install.ts +296 -0
- package/src/features/task-sync/commands/uninstall.ts +189 -0
- package/src/features/task-sync/commands/update.ts +11 -0
- package/src/features/task-sync/commands/verify.ts +141 -0
- package/src/features/task-sync/feature.json +12 -0
- package/src/features/task-sync/index.ts +121 -0
- package/src/features/task-sync/lib/dest-map.ts +96 -0
- package/src/features/task-sync/lib/drift-check.ts +47 -0
- package/src/features/task-sync/lib/file-ops.ts +36 -0
- package/src/features/task-sync/lib/manifest.ts +66 -0
- package/src/features/task-sync/lib/project-root.ts +38 -0
- package/src/features/task-sync/lib/settings-merge.ts +112 -0
- package/src/features/task-sync/lib/skill-refs.ts +106 -0
- package/src/features/task-sync/lib/task-id-finder.ts +31 -0
- package/src/features/task-sync/lib/token-extractor.ts +52 -0
- package/src/features/task-sync/lib/version.ts +36 -0
- package/src/features/task-sync/types.ts +40 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { parseArgs } from 'node:util';
|
|
3
|
+
import kleur from 'kleur';
|
|
4
|
+
import { getVersion } from './lib/version.js';
|
|
5
|
+
import * as install from './commands/install.js';
|
|
6
|
+
import * as uninstall from './commands/uninstall.js';
|
|
7
|
+
import * as verify from './commands/verify.js';
|
|
8
|
+
import * as update from './commands/update.js';
|
|
9
|
+
import type { CliOptions } from './types.js';
|
|
10
|
+
|
|
11
|
+
const USAGE = `
|
|
12
|
+
${kleur.bold('bitrix-skills')} — Synity Bitrix24 tooling CLI
|
|
13
|
+
|
|
14
|
+
${kleur.bold('Usage:')}
|
|
15
|
+
npx @synity/bitrix-skills <command> [options]
|
|
16
|
+
|
|
17
|
+
${kleur.bold('Commands:')}
|
|
18
|
+
install Copy hooks/scripts/docs + merge .claude/settings.json
|
|
19
|
+
uninstall Reverse install (removes managed files, cleans settings)
|
|
20
|
+
verify Smoke test live Bitrix webhook (requires TASK_ID + BITRIX_WEBHOOK_URL)
|
|
21
|
+
update Re-install managed files only (force overwrite, preserves user files)
|
|
22
|
+
|
|
23
|
+
${kleur.bold('Options:')}
|
|
24
|
+
--cwd=<path> Project root (default: auto-detect via .git/ or pnpm-workspace.yaml)
|
|
25
|
+
--dry-run Print planned actions, no FS changes
|
|
26
|
+
--force Overwrite user-modified managed files (install/update)
|
|
27
|
+
--no-githooks Skip .githooks/ + commit-msg setup (install)
|
|
28
|
+
--no-workflow Skip .github/workflows/bitrix-sync.yml (install)
|
|
29
|
+
--no-skill Skip ~/.claude/skills/bitrix-sync-install (install)
|
|
30
|
+
--keep-docs Preserve docs/bitrix-task-*.md (uninstall)
|
|
31
|
+
--remove-skill Also remove ~/.claude/skills/bitrix-sync-install (uninstall, default: preserve)
|
|
32
|
+
--quiet Skip live webhook call (verify — env + manifest only)
|
|
33
|
+
--version, -v Print version
|
|
34
|
+
--help, -h Print this help
|
|
35
|
+
|
|
36
|
+
${kleur.bold('Examples:')}
|
|
37
|
+
npx @synity/bitrix-skills install
|
|
38
|
+
npx @synity/bitrix-skills install --dry-run
|
|
39
|
+
npx @synity/bitrix-skills verify
|
|
40
|
+
npx @synity/bitrix-skills uninstall
|
|
41
|
+
`;
|
|
42
|
+
|
|
43
|
+
function parseCli(): { command: string | undefined; opts: CliOptions; help: boolean; version: boolean } {
|
|
44
|
+
const { values, positionals } = parseArgs({
|
|
45
|
+
args: process.argv.slice(2),
|
|
46
|
+
allowPositionals: true,
|
|
47
|
+
options: {
|
|
48
|
+
cwd: { type: 'string' },
|
|
49
|
+
'dry-run': { type: 'boolean', default: false },
|
|
50
|
+
force: { type: 'boolean', default: false },
|
|
51
|
+
'no-githooks': { type: 'boolean', default: false },
|
|
52
|
+
'no-workflow': { type: 'boolean', default: false },
|
|
53
|
+
'no-skill': { type: 'boolean', default: false },
|
|
54
|
+
'keep-docs': { type: 'boolean', default: false },
|
|
55
|
+
'remove-skill': { type: 'boolean', default: false },
|
|
56
|
+
quiet: { type: 'boolean', default: false },
|
|
57
|
+
version: { type: 'boolean', short: 'v', default: false },
|
|
58
|
+
help: { type: 'boolean', short: 'h', default: false },
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const opts: CliOptions = {
|
|
63
|
+
cwd: (values['cwd'] as string | undefined) ?? process.cwd(),
|
|
64
|
+
dryRun: values['dry-run'] as boolean,
|
|
65
|
+
force: values['force'] as boolean,
|
|
66
|
+
noGithooks: values['no-githooks'] as boolean,
|
|
67
|
+
noWorkflow: values['no-workflow'] as boolean,
|
|
68
|
+
noSkill: values['no-skill'] as boolean,
|
|
69
|
+
keepDocs: values['keep-docs'] as boolean,
|
|
70
|
+
removeSkill: values['remove-skill'] as boolean,
|
|
71
|
+
quiet: values['quiet'] as boolean,
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
command: positionals[0],
|
|
76
|
+
opts,
|
|
77
|
+
help: values['help'] as boolean,
|
|
78
|
+
version: values['version'] as boolean,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function main(): Promise<number> {
|
|
83
|
+
const { command, opts, help, version } = parseCli();
|
|
84
|
+
|
|
85
|
+
if (version) {
|
|
86
|
+
const v = await getVersion();
|
|
87
|
+
console.log(v);
|
|
88
|
+
return 0;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (help) {
|
|
92
|
+
console.log(USAGE);
|
|
93
|
+
return 0;
|
|
94
|
+
}
|
|
95
|
+
if (!command) {
|
|
96
|
+
console.log(USAGE);
|
|
97
|
+
return 2;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
switch (command) {
|
|
101
|
+
case 'install':
|
|
102
|
+
return install.run(opts);
|
|
103
|
+
case 'uninstall':
|
|
104
|
+
return uninstall.run(opts);
|
|
105
|
+
case 'verify':
|
|
106
|
+
return verify.run(opts);
|
|
107
|
+
case 'update':
|
|
108
|
+
return update.run(opts);
|
|
109
|
+
default:
|
|
110
|
+
console.error(kleur.red(`unknown command: ${command}`));
|
|
111
|
+
console.log(USAGE);
|
|
112
|
+
return 2;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
main()
|
|
117
|
+
.then((code) => process.exit(code))
|
|
118
|
+
.catch((err) => {
|
|
119
|
+
console.error(kleur.red(`\n fatal: ${err instanceof Error ? err.message : String(err)}\n`));
|
|
120
|
+
process.exit(1);
|
|
121
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Map manifest entry src (assets-relative) → project-relative dest path.
|
|
2
|
+
// Single source of truth for both install and uninstall logic.
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import { assertContainedIn } from '../../../lib/fs-safety.js';
|
|
6
|
+
import type { ManifestEntry, CliOptions } from '../types.js';
|
|
7
|
+
|
|
8
|
+
export interface DestEntry {
|
|
9
|
+
manifestEntry: ManifestEntry;
|
|
10
|
+
destAbs: string; // absolute path on FS
|
|
11
|
+
destRel: string; // human-readable rel path for output
|
|
12
|
+
category: 'script' | 'githook' | 'rule' | 'doc' | 'workflow' | 'skill';
|
|
13
|
+
optionalFlag?: keyof CliOptions; // skip if this opt is true
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function buildDestMap(
|
|
17
|
+
manifest: { files: ManifestEntry[] },
|
|
18
|
+
cwd: string,
|
|
19
|
+
): DestEntry[] {
|
|
20
|
+
const out: DestEntry[] = [];
|
|
21
|
+
const home = os.homedir();
|
|
22
|
+
const skillsRoot = path.join(home, '.claude', 'skills');
|
|
23
|
+
|
|
24
|
+
for (const entry of manifest.files) {
|
|
25
|
+
const src = entry.src;
|
|
26
|
+
if (src.startsWith('scripts/')) {
|
|
27
|
+
const name = src.slice('scripts/'.length);
|
|
28
|
+
const destRel = path.posix.join('.claude/scripts', name);
|
|
29
|
+
out.push({
|
|
30
|
+
manifestEntry: entry,
|
|
31
|
+
destAbs: path.resolve(cwd, destRel),
|
|
32
|
+
destRel,
|
|
33
|
+
category: 'script',
|
|
34
|
+
});
|
|
35
|
+
} else if (src.startsWith('githooks/')) {
|
|
36
|
+
const name = src.slice('githooks/'.length);
|
|
37
|
+
const destRel = path.posix.join('.githooks', name);
|
|
38
|
+
out.push({
|
|
39
|
+
manifestEntry: entry,
|
|
40
|
+
destAbs: path.resolve(cwd, destRel),
|
|
41
|
+
destRel,
|
|
42
|
+
category: 'githook',
|
|
43
|
+
optionalFlag: 'noGithooks',
|
|
44
|
+
});
|
|
45
|
+
} else if (src.startsWith('rules/')) {
|
|
46
|
+
const name = src.slice('rules/'.length);
|
|
47
|
+
const destRel = path.posix.join('.claude/rules', name);
|
|
48
|
+
out.push({
|
|
49
|
+
manifestEntry: entry,
|
|
50
|
+
destAbs: path.resolve(cwd, destRel),
|
|
51
|
+
destRel,
|
|
52
|
+
category: 'rule',
|
|
53
|
+
});
|
|
54
|
+
} else if (src.startsWith('docs/')) {
|
|
55
|
+
const name = src.slice('docs/'.length);
|
|
56
|
+
const destRel = path.posix.join('docs', name);
|
|
57
|
+
out.push({
|
|
58
|
+
manifestEntry: entry,
|
|
59
|
+
destAbs: path.resolve(cwd, destRel),
|
|
60
|
+
destRel,
|
|
61
|
+
category: 'doc',
|
|
62
|
+
});
|
|
63
|
+
} else if (src.startsWith('workflows/')) {
|
|
64
|
+
const name = src.slice('workflows/'.length);
|
|
65
|
+
const destRel = path.posix.join('.github/workflows', name);
|
|
66
|
+
out.push({
|
|
67
|
+
manifestEntry: entry,
|
|
68
|
+
destAbs: path.resolve(cwd, destRel),
|
|
69
|
+
destRel,
|
|
70
|
+
category: 'workflow',
|
|
71
|
+
optionalFlag: 'noWorkflow',
|
|
72
|
+
});
|
|
73
|
+
} else if (src.startsWith('skill/')) {
|
|
74
|
+
const name = src.slice('skill/'.length);
|
|
75
|
+
// Skill installs to user home (per Phase 04 spec): ~/.claude/skills/bitrix-sync-install/
|
|
76
|
+
const destAbs = path.resolve(home, '.claude', 'skills', 'bitrix-sync-install', name);
|
|
77
|
+
const destRel = path.posix.join('~/.claude/skills/bitrix-sync-install', name);
|
|
78
|
+
out.push({
|
|
79
|
+
manifestEntry: entry,
|
|
80
|
+
destAbs,
|
|
81
|
+
destRel,
|
|
82
|
+
category: 'skill',
|
|
83
|
+
optionalFlag: 'noSkill',
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
// Unknown prefix: silently ignore (forward-compatible)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Containment check: guards against path-traversal in manifest entries.
|
|
90
|
+
for (const e of out) {
|
|
91
|
+
const allowedRoot = e.category === 'skill' ? skillsRoot : cwd;
|
|
92
|
+
assertContainedIn(e.destAbs, allowedRoot, e.manifestEntry.src);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Compare each managed file's sha256 vs manifest expectation.
|
|
2
|
+
// Returns drifted (modified content) and missing entries separately.
|
|
3
|
+
import type { Manifest, CliOptions } from '../types.js';
|
|
4
|
+
import { fileExists, sha256File } from './file-ops.js';
|
|
5
|
+
import { buildDestMap } from './dest-map.js';
|
|
6
|
+
|
|
7
|
+
export interface DriftEntry {
|
|
8
|
+
path: string;
|
|
9
|
+
reason: 'modified' | 'missing';
|
|
10
|
+
expected: string;
|
|
11
|
+
actual: string | null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function checkDrift(
|
|
15
|
+
manifest: Manifest,
|
|
16
|
+
cwd: string,
|
|
17
|
+
opts: Partial<CliOptions> = {},
|
|
18
|
+
): Promise<DriftEntry[]> {
|
|
19
|
+
const drifted: DriftEntry[] = [];
|
|
20
|
+
const dests = buildDestMap(manifest, cwd);
|
|
21
|
+
|
|
22
|
+
for (const d of dests) {
|
|
23
|
+
// Skip categories opted out
|
|
24
|
+
if (d.optionalFlag && opts[d.optionalFlag]) continue;
|
|
25
|
+
|
|
26
|
+
if (!(await fileExists(d.destAbs))) {
|
|
27
|
+
drifted.push({
|
|
28
|
+
path: d.destRel,
|
|
29
|
+
reason: 'missing',
|
|
30
|
+
expected: d.manifestEntry.sha256,
|
|
31
|
+
actual: null,
|
|
32
|
+
});
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
const actual = await sha256File(d.destAbs);
|
|
36
|
+
if (actual !== d.manifestEntry.sha256) {
|
|
37
|
+
drifted.push({
|
|
38
|
+
path: d.destRel,
|
|
39
|
+
reason: 'modified',
|
|
40
|
+
expected: d.manifestEntry.sha256,
|
|
41
|
+
actual,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return drifted;
|
|
47
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// File system helpers: sha256, existence check, atomic write.
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { readFile, access, mkdir, writeFile, rename, chmod } from 'node:fs/promises';
|
|
4
|
+
import { constants } from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
|
|
7
|
+
export async function sha256File(filePath: string): Promise<string> {
|
|
8
|
+
const buf = await readFile(filePath);
|
|
9
|
+
return createHash('sha256').update(buf).digest('hex');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function sha256Buffer(buf: Buffer | Uint8Array): string {
|
|
13
|
+
return createHash('sha256').update(buf).digest('hex');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function fileExists(filePath: string): Promise<boolean> {
|
|
17
|
+
try {
|
|
18
|
+
await access(filePath, constants.F_OK);
|
|
19
|
+
return true;
|
|
20
|
+
} catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function ensureDir(dirPath: string): Promise<void> {
|
|
26
|
+
await mkdir(dirPath, { recursive: true });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Atomic write: write to temp file, then rename. Avoids partial writes.
|
|
30
|
+
export async function atomicWrite(filePath: string, content: string | Buffer, mode = 0o644): Promise<void> {
|
|
31
|
+
await ensureDir(path.dirname(filePath));
|
|
32
|
+
const tmp = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
33
|
+
await writeFile(tmp, content, { mode });
|
|
34
|
+
await chmod(tmp, mode);
|
|
35
|
+
await rename(tmp, filePath);
|
|
36
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Load + validate assets/manifest.json relative to package install location.
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import type { Manifest } from '../types.js';
|
|
7
|
+
import { fileExists } from './file-ops.js';
|
|
8
|
+
|
|
9
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
|
|
11
|
+
// Resolve the task-sync feature assets/ dir (contains hook scripts + manifest.json).
|
|
12
|
+
// This code gets inlined into dist/cli.js (__dirname = dist/) AND dist/features/task-sync/index.js
|
|
13
|
+
// (__dirname = dist/features/task-sync/). Both paths must be covered by the candidates below.
|
|
14
|
+
export function resolveAssetsDir(): string {
|
|
15
|
+
const candidates = [
|
|
16
|
+
// From dist/ (cli.js context): ../src/features/task-sync/assets
|
|
17
|
+
path.resolve(__dirname, '../src/features/task-sync/assets'),
|
|
18
|
+
// From dist/features/task-sync/ (index.js context): ../../../src/features/task-sync/assets
|
|
19
|
+
path.resolve(__dirname, '../../../src/features/task-sync/assets'),
|
|
20
|
+
// Dev: src/features/task-sync/lib/ → ../assets
|
|
21
|
+
path.resolve(__dirname, '../assets'),
|
|
22
|
+
// Legacy fallbacks (should not match before correct paths above)
|
|
23
|
+
path.resolve(__dirname, '../../assets'),
|
|
24
|
+
path.resolve(__dirname, '../../../assets'),
|
|
25
|
+
path.resolve(__dirname, '../../../../assets'),
|
|
26
|
+
];
|
|
27
|
+
for (const c of candidates) {
|
|
28
|
+
if (existsSync(path.join(c, 'manifest.json'))) {
|
|
29
|
+
return c;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
// Fallback to first candidate; loadManifest will throw clearer error
|
|
33
|
+
return candidates[0]!;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function loadManifest(): Promise<Manifest> {
|
|
37
|
+
const dir = resolveAssetsDir();
|
|
38
|
+
const manifestPath = path.join(dir, 'manifest.json');
|
|
39
|
+
if (!(await fileExists(manifestPath))) {
|
|
40
|
+
throw new Error(`assets/manifest.json not found at ${manifestPath} (was 'pnpm build' run?)`);
|
|
41
|
+
}
|
|
42
|
+
const raw = await readFile(manifestPath, 'utf8');
|
|
43
|
+
let parsed: unknown;
|
|
44
|
+
try {
|
|
45
|
+
parsed = JSON.parse(raw);
|
|
46
|
+
} catch (err) {
|
|
47
|
+
throw new Error(`malformed manifest.json: ${(err as Error).message}`);
|
|
48
|
+
}
|
|
49
|
+
validateManifest(parsed);
|
|
50
|
+
return parsed as Manifest;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function validateManifest(obj: unknown): asserts obj is Manifest {
|
|
54
|
+
if (!obj || typeof obj !== 'object') throw new Error('manifest must be an object');
|
|
55
|
+
const m = obj as Record<string, unknown>;
|
|
56
|
+
if (typeof m['version'] !== 'string') throw new Error('manifest.version must be string');
|
|
57
|
+
if (!Array.isArray(m['files'])) throw new Error('manifest.files must be array');
|
|
58
|
+
for (const f of m['files']) {
|
|
59
|
+
if (!f || typeof f !== 'object') throw new Error('manifest.files[] entry must be object');
|
|
60
|
+
const e = f as Record<string, unknown>;
|
|
61
|
+
if (typeof e['src'] !== 'string') throw new Error('manifest entry.src must be string');
|
|
62
|
+
if (typeof e['sha256'] !== 'string') throw new Error('manifest entry.sha256 must be string');
|
|
63
|
+
if (typeof e['size'] !== 'number') throw new Error('manifest entry.size must be number');
|
|
64
|
+
if (typeof e['mode'] !== 'number') throw new Error('manifest entry.mode must be number');
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Walk up from cwd looking for project root markers (.git/, pnpm-workspace.yaml, package.json).
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileExists } from './file-ops.js';
|
|
4
|
+
|
|
5
|
+
const ROOT_MARKERS = ['.git', 'pnpm-workspace.yaml'];
|
|
6
|
+
|
|
7
|
+
export async function detectProjectRoot(startDir: string): Promise<string> {
|
|
8
|
+
let dir = path.resolve(startDir);
|
|
9
|
+
const root = path.parse(dir).root;
|
|
10
|
+
while (true) {
|
|
11
|
+
for (const marker of ROOT_MARKERS) {
|
|
12
|
+
if (await fileExists(path.join(dir, marker))) {
|
|
13
|
+
return dir;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
const parent = path.dirname(dir);
|
|
17
|
+
if (parent === dir || dir === root) {
|
|
18
|
+
break;
|
|
19
|
+
}
|
|
20
|
+
dir = parent;
|
|
21
|
+
}
|
|
22
|
+
// Fallback: if no marker found, walk up looking for any package.json
|
|
23
|
+
dir = path.resolve(startDir);
|
|
24
|
+
while (true) {
|
|
25
|
+
if (await fileExists(path.join(dir, 'package.json'))) {
|
|
26
|
+
return dir;
|
|
27
|
+
}
|
|
28
|
+
const parent = path.dirname(dir);
|
|
29
|
+
if (parent === dir) break;
|
|
30
|
+
dir = parent;
|
|
31
|
+
}
|
|
32
|
+
// Last resort: use the original cwd (allows install in empty/fresh dirs)
|
|
33
|
+
return path.resolve(startDir);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function hasGitDir(rootDir: string): Promise<boolean> {
|
|
37
|
+
return fileExists(path.join(rootDir, '.git'));
|
|
38
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// .claude/settings.json deep-merge + Bitrix hook subtraction.
|
|
2
|
+
// Idempotent: merging template twice yields the same result (dedup by JSON identity).
|
|
3
|
+
import { readFile } from 'node:fs/promises';
|
|
4
|
+
import deepmerge from 'deepmerge';
|
|
5
|
+
import { fileExists, atomicWrite } from './file-ops.js';
|
|
6
|
+
|
|
7
|
+
export interface ClaudeHookEntry {
|
|
8
|
+
matcher?: string;
|
|
9
|
+
hooks?: Array<{ type?: string; command?: string }>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ClaudeSettings {
|
|
13
|
+
hooks?: {
|
|
14
|
+
SessionStart?: ClaudeHookEntry[];
|
|
15
|
+
PreToolUse?: ClaudeHookEntry[];
|
|
16
|
+
PostToolUse?: ClaudeHookEntry[];
|
|
17
|
+
Stop?: ClaudeHookEntry[];
|
|
18
|
+
[key: string]: ClaudeHookEntry[] | undefined;
|
|
19
|
+
};
|
|
20
|
+
[key: string]: unknown;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Template: 4 hooks Bitrix Task Sync registers.
|
|
24
|
+
// Commands reference scripts at runtime; full hook keys / matchers MUST exactly match
|
|
25
|
+
// to enable idempotent merge + clean uninstall subtraction.
|
|
26
|
+
export const BITRIX_HOOKS_TEMPLATE: ClaudeSettings = {
|
|
27
|
+
hooks: {
|
|
28
|
+
SessionStart: [
|
|
29
|
+
{ hooks: [{ type: 'command', command: 'bash .claude/scripts/bitrix-session-check.sh' }] },
|
|
30
|
+
],
|
|
31
|
+
PreToolUse: [
|
|
32
|
+
{ matcher: 'Skill', hooks: [{ type: 'command', command: 'bash .claude/scripts/bitrix-skill-start.sh' }] },
|
|
33
|
+
],
|
|
34
|
+
PostToolUse: [
|
|
35
|
+
{ matcher: 'Skill', hooks: [{ type: 'command', command: 'bash .claude/scripts/bitrix-skill-end.sh' }] },
|
|
36
|
+
],
|
|
37
|
+
Stop: [
|
|
38
|
+
{ hooks: [{ type: 'command', command: 'bash .claude/scripts/bitrix-session-sync.sh' }] },
|
|
39
|
+
],
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// Custom array merge: concat then dedupe by serialized JSON.
|
|
44
|
+
// Edge case: identical entries → kept once (idempotent re-install).
|
|
45
|
+
// Edge case: same matcher but different command → both preserved (user has multiple hooks).
|
|
46
|
+
function arrayMerge<T>(target: T[], source: T[]): T[] {
|
|
47
|
+
const combined = [...target, ...source];
|
|
48
|
+
const seen = new Set<string>();
|
|
49
|
+
const result: T[] = [];
|
|
50
|
+
for (const item of combined) {
|
|
51
|
+
const key = JSON.stringify(item);
|
|
52
|
+
if (seen.has(key)) continue;
|
|
53
|
+
seen.add(key);
|
|
54
|
+
result.push(item);
|
|
55
|
+
}
|
|
56
|
+
return result;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function mergeSettings(existing: ClaudeSettings, template: ClaudeSettings): ClaudeSettings {
|
|
60
|
+
return deepmerge(existing, template, {
|
|
61
|
+
arrayMerge,
|
|
62
|
+
clone: true,
|
|
63
|
+
}) as ClaudeSettings;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function loadSettingsFile(filePath: string): Promise<ClaudeSettings> {
|
|
67
|
+
if (!(await fileExists(filePath))) return {};
|
|
68
|
+
const raw = await readFile(filePath, 'utf8');
|
|
69
|
+
if (!raw.trim()) return {};
|
|
70
|
+
try {
|
|
71
|
+
const parsed = JSON.parse(raw);
|
|
72
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
73
|
+
throw new Error('settings.json must be a JSON object');
|
|
74
|
+
}
|
|
75
|
+
return parsed as ClaudeSettings;
|
|
76
|
+
} catch (err) {
|
|
77
|
+
throw new Error(`malformed settings.json (${filePath}): ${(err as Error).message}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function writeSettingsFile(filePath: string, obj: ClaudeSettings): Promise<void> {
|
|
82
|
+
const json = JSON.stringify(obj, null, 2) + '\n';
|
|
83
|
+
await atomicWrite(filePath, json, 0o644);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Predicate: a hook entry was installed by us (matches Bitrix script command pattern).
|
|
87
|
+
function isBitrixHookEntry(entry: ClaudeHookEntry): boolean {
|
|
88
|
+
const cmds = (entry.hooks ?? []).map((h) => h.command).filter((c): c is string => typeof c === 'string');
|
|
89
|
+
return cmds.some((c) => /\.claude\/scripts\/bitrix-/.test(c));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Strip Bitrix hooks from settings — preserves user's other hooks.
|
|
93
|
+
// Empties trigger array → removes trigger key. Empties hooks object → removes hooks key.
|
|
94
|
+
export function removeBitrixHooks(settings: ClaudeSettings): ClaudeSettings {
|
|
95
|
+
const cloned: ClaudeSettings = JSON.parse(JSON.stringify(settings));
|
|
96
|
+
const hooks = cloned.hooks;
|
|
97
|
+
if (!hooks) return cloned;
|
|
98
|
+
for (const trigger of Object.keys(hooks)) {
|
|
99
|
+
const list = hooks[trigger];
|
|
100
|
+
if (!Array.isArray(list)) continue;
|
|
101
|
+
const filtered = list.filter((entry) => !isBitrixHookEntry(entry));
|
|
102
|
+
if (filtered.length === 0) {
|
|
103
|
+
delete hooks[trigger];
|
|
104
|
+
} else {
|
|
105
|
+
hooks[trigger] = filtered;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (Object.keys(hooks).length === 0) {
|
|
109
|
+
delete cloned.hooks;
|
|
110
|
+
}
|
|
111
|
+
return cloned;
|
|
112
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// User-scope skill is installed once at ~/.claude/skills/bitrix-sync-install/
|
|
2
|
+
// and shared across every Synity project on the machine. We track which
|
|
3
|
+
// projects have referenced it via a sidecar refs.json so uninstall in one
|
|
4
|
+
// project never breaks another.
|
|
5
|
+
//
|
|
6
|
+
// Paths are resolved lazily via getters so tests can override HOME at runtime
|
|
7
|
+
// to keep the user's real ~/.claude tree untouched.
|
|
8
|
+
import { readFile, writeFile, mkdir, rm, access } from 'node:fs/promises';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import os from 'node:os';
|
|
11
|
+
|
|
12
|
+
export function getSkillDir(): string {
|
|
13
|
+
return path.join(os.homedir(), '.claude/skills/bitrix-sync-install');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function getRefsFile(): string {
|
|
17
|
+
return path.join(getSkillDir(), '.refs.json');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface SkillRefs {
|
|
21
|
+
version: 1;
|
|
22
|
+
projects: string[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function loadRefs(): Promise<SkillRefs> {
|
|
26
|
+
try {
|
|
27
|
+
const raw = await readFile(getRefsFile(), 'utf8');
|
|
28
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
29
|
+
if (
|
|
30
|
+
parsed &&
|
|
31
|
+
typeof parsed === 'object' &&
|
|
32
|
+
Array.isArray((parsed as SkillRefs).projects)
|
|
33
|
+
) {
|
|
34
|
+
return { version: 1, projects: [...(parsed as SkillRefs).projects] };
|
|
35
|
+
}
|
|
36
|
+
} catch {
|
|
37
|
+
// missing or malformed → start fresh
|
|
38
|
+
}
|
|
39
|
+
return { version: 1, projects: [] };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function saveRefs(refs: SkillRefs): Promise<void> {
|
|
43
|
+
await mkdir(getSkillDir(), { recursive: true });
|
|
44
|
+
await writeFile(getRefsFile(), JSON.stringify(refs, null, 2) + '\n', 'utf8');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Add a project path to the refs set. Returns the post-add list.
|
|
48
|
+
// Idempotent — re-adding an existing path is a no-op.
|
|
49
|
+
export async function addProjectRef(projectPath: string): Promise<string[]> {
|
|
50
|
+
const abs = path.resolve(projectPath);
|
|
51
|
+
const refs = await loadRefs();
|
|
52
|
+
if (!refs.projects.includes(abs)) {
|
|
53
|
+
refs.projects.push(abs);
|
|
54
|
+
await saveRefs(refs);
|
|
55
|
+
}
|
|
56
|
+
return refs.projects;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Remove a project from the refs set. Returns:
|
|
60
|
+
// { remaining: number, removed: boolean }
|
|
61
|
+
// `removed: true` only if the project was previously listed.
|
|
62
|
+
export async function removeProjectRef(
|
|
63
|
+
projectPath: string,
|
|
64
|
+
): Promise<{ remaining: number; removed: boolean }> {
|
|
65
|
+
const abs = path.resolve(projectPath);
|
|
66
|
+
const refs = await loadRefs();
|
|
67
|
+
const before = refs.projects.length;
|
|
68
|
+
refs.projects = refs.projects.filter((p) => p !== abs);
|
|
69
|
+
const removed = refs.projects.length < before;
|
|
70
|
+
if (refs.projects.length === 0) {
|
|
71
|
+
// Caller decides whether to delete the directory. We just clear refs.
|
|
72
|
+
try {
|
|
73
|
+
await rm(getRefsFile(), { force: true });
|
|
74
|
+
} catch {
|
|
75
|
+
// non-fatal
|
|
76
|
+
}
|
|
77
|
+
} else if (removed) {
|
|
78
|
+
await saveRefs(refs);
|
|
79
|
+
}
|
|
80
|
+
return { remaining: refs.projects.length, removed };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function getRefs(): Promise<string[]> {
|
|
84
|
+
const refs = await loadRefs();
|
|
85
|
+
return refs.projects;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Wipes the skill directory + refs sidecar entirely. Used when --remove-skill is passed —
|
|
89
|
+
// the user has chosen to nuke the shared directory regardless of who
|
|
90
|
+
// else still references it.
|
|
91
|
+
export async function clearAllRefs(): Promise<void> {
|
|
92
|
+
try {
|
|
93
|
+
await rm(getSkillDir(), { recursive: true, force: true });
|
|
94
|
+
} catch {
|
|
95
|
+
// non-fatal
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function skillDirExists(): Promise<boolean> {
|
|
100
|
+
try {
|
|
101
|
+
await access(getSkillDir());
|
|
102
|
+
return true;
|
|
103
|
+
} catch {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// TS port of bash `find_task_id` from .claude/scripts/bitrix-lib.sh.
|
|
2
|
+
// Walks up from startDir looking for `TASK_ID:` line in CLAUDE.md.
|
|
3
|
+
import { readFile } from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileExists } from './file-ops.js';
|
|
6
|
+
|
|
7
|
+
export async function findTaskId(startDir: string): Promise<string | null> {
|
|
8
|
+
let dir = path.resolve(startDir);
|
|
9
|
+
const root = path.parse(dir).root;
|
|
10
|
+
while (true) {
|
|
11
|
+
const md = path.join(dir, 'CLAUDE.md');
|
|
12
|
+
if (await fileExists(md)) {
|
|
13
|
+
const content = await readFile(md, 'utf8');
|
|
14
|
+
const match = content.match(/^TASK_ID:\s*(\S+)/m);
|
|
15
|
+
if (match && match[1]) {
|
|
16
|
+
// Strip trailing CR (CRLF files) — \S in JS regex matches CR
|
|
17
|
+
return match[1].replace(/\r$/, '');
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
const parent = path.dirname(dir);
|
|
21
|
+
if (parent === dir || dir === root) break;
|
|
22
|
+
dir = parent;
|
|
23
|
+
}
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// TASK_ID must be numeric (Bitrix task ID). Reject anything else to prevent
|
|
28
|
+
// shell injection when later passed to `bash -c`.
|
|
29
|
+
export function isValidTaskId(taskId: string): boolean {
|
|
30
|
+
return /^\d+$/.test(taskId);
|
|
31
|
+
}
|