@produck/agent-toolkit 0.1.5 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -24
- package/bin/agent-toolkit.mjs +51 -459
- package/bin/build-publish-assets.mjs +105 -0
- package/bin/command/main/index.mjs +11 -0
- package/bin/command/preflight/index.mjs +58 -0
- package/bin/command/run-capture/index.mjs +100 -0
- package/bin/command/shared/args.mjs +45 -0
- package/bin/command/shared/text-resource.mjs +19 -0
- package/bin/command/summarize-log/index.mjs +64 -0
- package/bin/command/sync-instructions/help.txt +11 -0
- package/bin/command/sync-instructions/index.mjs +272 -0
- package/bin/command/sync-instructions/user-space-bootstrap.md +14 -0
- package/bin/command/validate-commit-msg/help.txt +8 -0
- package/bin/command/validate-commit-msg/index.mjs +152 -0
- package/package.json +7 -3
- package/publish-assets/instructions/produck/00-produck-base.instructions.md +331 -0
- package/publish-assets/instructions/produck/10-produck-node.instructions.md +152 -0
- package/publish-assets/instructions/produck/15-produck-workspace.instructions.md +299 -0
- package/publish-assets/instructions/produck/20-produck-commit.instructions.md +178 -0
- package/templates/default.instructions.md +0 -21
- package/templates/help/sync-instructions.txt +0 -8
- package/templates/help/validate-commit-msg.txt +0 -7
- /package/{templates/help/main.txt → bin/command/main/help.txt} +0 -0
- /package/{templates/help/preflight.txt → bin/command/preflight/help.txt} +0 -0
- /package/{templates/help/run-capture.txt → bin/command/run-capture/help.txt} +0 -0
- /package/{templates/help/summarize-log.txt → bin/command/summarize-log/help.txt} +0 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
|
|
6
|
+
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
const PACKAGE_ROOT = path.resolve(SCRIPT_DIR, '..');
|
|
8
|
+
const REPO_ROOT = path.resolve(PACKAGE_ROOT, '../..');
|
|
9
|
+
const SOURCE_DIR = path.resolve(REPO_ROOT, '.github/distribution/produck');
|
|
10
|
+
const OUTPUT_DIR = path.resolve(PACKAGE_ROOT, 'publish-assets/instructions/produck');
|
|
11
|
+
const LEGACY_OUTPUT_PATH = path.resolve(
|
|
12
|
+
PACKAGE_ROOT,
|
|
13
|
+
'publish-assets/instructions/org.instructions.md',
|
|
14
|
+
);
|
|
15
|
+
const MANAGED_MARKER = '<!-- managed-by: @produck/agent-toolkit -->';
|
|
16
|
+
|
|
17
|
+
function normalize(text) {
|
|
18
|
+
return text.replace(/\r\n/g, '\n').trimEnd() + '\n';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function readFrontmatter(text) {
|
|
22
|
+
const match = text.match(/^---\n([\s\S]*?)\n---\n/);
|
|
23
|
+
if (!match) {
|
|
24
|
+
return '';
|
|
25
|
+
}
|
|
26
|
+
return match[1];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function validateSourceFile(fileName, text) {
|
|
30
|
+
const frontmatter = readFrontmatter(text);
|
|
31
|
+
if (!frontmatter) {
|
|
32
|
+
throw new Error(`Missing frontmatter in source file: ${fileName}`);
|
|
33
|
+
}
|
|
34
|
+
if (!/^applyTo:\s*["'][^"']+["']\s*$/m.test(frontmatter)) {
|
|
35
|
+
throw new Error(`Missing applyTo in source file: ${fileName}`);
|
|
36
|
+
}
|
|
37
|
+
if (!text.includes(MANAGED_MARKER)) {
|
|
38
|
+
throw new Error(`Missing managed marker in source file: ${fileName}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function readSourceEntries() {
|
|
43
|
+
if (!fs.existsSync(SOURCE_DIR)) {
|
|
44
|
+
throw new Error(`Missing source directory: ${SOURCE_DIR}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const fileNames = fs
|
|
48
|
+
.readdirSync(SOURCE_DIR)
|
|
49
|
+
.filter((name) => name.endsWith('.instructions.md'))
|
|
50
|
+
.sort((a, b) => a.localeCompare(b));
|
|
51
|
+
|
|
52
|
+
if (fileNames.length === 0) {
|
|
53
|
+
throw new Error(`No source instruction files in: ${SOURCE_DIR}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return fileNames.map((fileName) => {
|
|
57
|
+
const sourcePath = path.resolve(SOURCE_DIR, fileName);
|
|
58
|
+
const text = normalize(fs.readFileSync(sourcePath, 'utf8'));
|
|
59
|
+
validateSourceFile(fileName, text);
|
|
60
|
+
return {
|
|
61
|
+
fileName,
|
|
62
|
+
sourcePath,
|
|
63
|
+
text,
|
|
64
|
+
};
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function cleanStaleManagedFiles(expectedNames) {
|
|
69
|
+
if (!fs.existsSync(OUTPUT_DIR)) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const existing = fs.readdirSync(OUTPUT_DIR).filter((name) => name.endsWith('.instructions.md'));
|
|
73
|
+
for (const name of existing) {
|
|
74
|
+
if (expectedNames.has(name)) {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const filePath = path.resolve(OUTPUT_DIR, name);
|
|
78
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
79
|
+
if (content.includes(MANAGED_MARKER)) {
|
|
80
|
+
fs.unlinkSync(filePath);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function run() {
|
|
86
|
+
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
|
87
|
+
|
|
88
|
+
const sourceEntries = readSourceEntries();
|
|
89
|
+
const expectedNames = new Set(sourceEntries.map((entry) => entry.fileName));
|
|
90
|
+
|
|
91
|
+
for (const entry of sourceEntries) {
|
|
92
|
+
const outPath = path.resolve(OUTPUT_DIR, entry.fileName);
|
|
93
|
+
fs.writeFileSync(outPath, entry.text, 'utf8');
|
|
94
|
+
process.stdout.write(`Generated ${outPath} from ${entry.sourcePath}\n`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
cleanStaleManagedFiles(expectedNames);
|
|
98
|
+
|
|
99
|
+
if (fs.existsSync(LEGACY_OUTPUT_PATH)) {
|
|
100
|
+
fs.unlinkSync(LEGACY_OUTPUT_PATH);
|
|
101
|
+
process.stdout.write(`Removed legacy ${LEGACY_OUTPUT_PATH}\n`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
run();
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
|
|
4
|
+
import { printTextResource } from '../shared/text-resource.mjs';
|
|
5
|
+
|
|
6
|
+
const COMMAND_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
const HELP_FILE = path.resolve(COMMAND_DIR, 'help.txt');
|
|
8
|
+
|
|
9
|
+
export function printMainHelp() {
|
|
10
|
+
printTextResource(HELP_FILE);
|
|
11
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
import { getMulti, getSingle } from '../shared/args.mjs';
|
|
6
|
+
import { printTextResource } from '../shared/text-resource.mjs';
|
|
7
|
+
|
|
8
|
+
const COMMAND_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
const HELP_FILE = path.resolve(COMMAND_DIR, 'help.txt');
|
|
10
|
+
|
|
11
|
+
export function printPreflightHelp() {
|
|
12
|
+
printTextResource(HELP_FILE);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function runPreflight(options) {
|
|
16
|
+
const cwd = path.resolve(getSingle(options, '--cwd', process.cwd()));
|
|
17
|
+
const required = getMulti(options, '--require');
|
|
18
|
+
const ensureDir = getMulti(options, '--ensure-dir');
|
|
19
|
+
const jsonFile = getSingle(options, '--json', '');
|
|
20
|
+
|
|
21
|
+
if (!fs.existsSync(cwd)) {
|
|
22
|
+
console.error(`CWD does not exist: ${cwd}`);
|
|
23
|
+
process.exit(2);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const report = {
|
|
27
|
+
cwd,
|
|
28
|
+
required: [],
|
|
29
|
+
ensuredDirectories: [],
|
|
30
|
+
ok: true,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
for (const rel of required) {
|
|
34
|
+
const resolved = path.resolve(cwd, rel);
|
|
35
|
+
const exists = fs.existsSync(resolved);
|
|
36
|
+
report.required.push({ path: rel, resolved, exists });
|
|
37
|
+
if (!exists) {
|
|
38
|
+
report.ok = false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
for (const rel of ensureDir) {
|
|
43
|
+
const resolved = path.resolve(cwd, rel);
|
|
44
|
+
fs.mkdirSync(resolved, { recursive: true });
|
|
45
|
+
report.ensuredDirectories.push({ path: rel, resolved, created: true });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (jsonFile) {
|
|
49
|
+
const out = path.resolve(cwd, jsonFile);
|
|
50
|
+
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
51
|
+
fs.writeFileSync(out, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
55
|
+
if (!report.ok) {
|
|
56
|
+
process.exit(2);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
|
|
6
|
+
import { getSingle, hasFlag } from '../shared/args.mjs';
|
|
7
|
+
import { printTextResource } from '../shared/text-resource.mjs';
|
|
8
|
+
|
|
9
|
+
const COMMAND_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const HELP_FILE = path.resolve(COMMAND_DIR, 'help.txt');
|
|
11
|
+
|
|
12
|
+
export function printRunCaptureHelp() {
|
|
13
|
+
printTextResource(HELP_FILE);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function runCapture(options) {
|
|
17
|
+
const out = getSingle(options, '--out', '');
|
|
18
|
+
const cmd = getSingle(options, '--cmd', '');
|
|
19
|
+
const cwd = path.resolve(getSingle(options, '--cwd', process.cwd()));
|
|
20
|
+
const meta = getSingle(options, '--meta', '');
|
|
21
|
+
const allowPipe = hasFlag(options, '--allow-pipe');
|
|
22
|
+
|
|
23
|
+
if (!out || !cmd) {
|
|
24
|
+
printRunCaptureHelp();
|
|
25
|
+
process.exit(2);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (!allowPipe && cmd.includes('|')) {
|
|
29
|
+
console.error('Blocked command containing pipe. Use --allow-pipe if needed.');
|
|
30
|
+
process.exit(2);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const outPath = path.resolve(out);
|
|
34
|
+
const metaPath = meta ? path.resolve(meta) : `${outPath}.meta.json`;
|
|
35
|
+
|
|
36
|
+
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
37
|
+
fs.mkdirSync(path.dirname(metaPath), { recursive: true });
|
|
38
|
+
|
|
39
|
+
const startAt = Date.now();
|
|
40
|
+
const outStream = fs.createWriteStream(outPath, { encoding: 'utf8' });
|
|
41
|
+
|
|
42
|
+
outStream.write(`# command: ${cmd}\n`);
|
|
43
|
+
outStream.write(`# cwd: ${cwd}\n`);
|
|
44
|
+
outStream.write(`# startedAt: ${new Date(startAt).toISOString()}\n\n`);
|
|
45
|
+
|
|
46
|
+
const child = spawn(cmd, {
|
|
47
|
+
cwd,
|
|
48
|
+
shell: true,
|
|
49
|
+
env: process.env,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
let stdoutBytes = 0;
|
|
53
|
+
let stderrBytes = 0;
|
|
54
|
+
|
|
55
|
+
child.stdout.on('data', (chunk) => {
|
|
56
|
+
stdoutBytes += chunk.length;
|
|
57
|
+
outStream.write(chunk);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
child.stderr.on('data', (chunk) => {
|
|
61
|
+
stderrBytes += chunk.length;
|
|
62
|
+
outStream.write(chunk);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
child.on('error', (error) => {
|
|
66
|
+
outStream.write(`\n[agent-toolkit] spawn error: ${error.message}\n`);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
child.on('close', (code, signal) => {
|
|
70
|
+
const endAt = Date.now();
|
|
71
|
+
const durationMs = endAt - startAt;
|
|
72
|
+
const signalLabel = String(signal).replace(/^null$/, 'none');
|
|
73
|
+
const numberCode = Number(code);
|
|
74
|
+
const isNumberCode = Number(typeof code === 'number');
|
|
75
|
+
const normalizedExitCode = isNumberCode * numberCode + (1 - isNumberCode);
|
|
76
|
+
|
|
77
|
+
outStream.write('\n');
|
|
78
|
+
outStream.write(`# finishedAt: ${new Date(endAt).toISOString()}\n`);
|
|
79
|
+
outStream.write(`# exitCode: ${String(code)}\n`);
|
|
80
|
+
outStream.write(`# signal: ${signalLabel}\n`);
|
|
81
|
+
outStream.end();
|
|
82
|
+
|
|
83
|
+
const report = {
|
|
84
|
+
command: cmd,
|
|
85
|
+
cwd,
|
|
86
|
+
startedAt: new Date(startAt).toISOString(),
|
|
87
|
+
finishedAt: new Date(endAt).toISOString(),
|
|
88
|
+
durationMs,
|
|
89
|
+
exitCode: code,
|
|
90
|
+
signal,
|
|
91
|
+
stdoutBytes,
|
|
92
|
+
stderrBytes,
|
|
93
|
+
outputFile: outPath,
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
fs.writeFileSync(metaPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
|
|
97
|
+
|
|
98
|
+
process.exit(normalizedExitCode);
|
|
99
|
+
});
|
|
100
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export function parseCommonArgs(argv) {
|
|
2
|
+
const positional = [];
|
|
3
|
+
const options = {};
|
|
4
|
+
|
|
5
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
6
|
+
const token = argv[i];
|
|
7
|
+
if (token.startsWith('--')) {
|
|
8
|
+
const next = argv[i + 1];
|
|
9
|
+
if (!next || next.startsWith('--')) {
|
|
10
|
+
if (!options[token]) {
|
|
11
|
+
options[token] = [];
|
|
12
|
+
}
|
|
13
|
+
options[token].push(true);
|
|
14
|
+
} else {
|
|
15
|
+
if (!options[token]) {
|
|
16
|
+
options[token] = [];
|
|
17
|
+
}
|
|
18
|
+
options[token].push(next);
|
|
19
|
+
i += 1;
|
|
20
|
+
}
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
positional.push(token);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return { positional, options };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function getSingle(options, key, fallback = '') {
|
|
30
|
+
if (!options[key] || options[key].length === 0) {
|
|
31
|
+
return fallback;
|
|
32
|
+
}
|
|
33
|
+
return String(options[key][options[key].length - 1]);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function getMulti(options, key) {
|
|
37
|
+
if (!options[key]) {
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
return options[key].map((v) => String(v));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function hasFlag(options, key) {
|
|
44
|
+
return Boolean(options[key]);
|
|
45
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
|
|
3
|
+
export function loadTextResource(filePath) {
|
|
4
|
+
if (!fs.existsSync(filePath)) {
|
|
5
|
+
console.error(`Resource file not found: ${filePath}`);
|
|
6
|
+
process.exit(2);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
return fs.readFileSync(filePath, 'utf8');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function printTextResource(filePath) {
|
|
13
|
+
let content = loadTextResource(filePath);
|
|
14
|
+
if (!content.endsWith('\n')) {
|
|
15
|
+
content = `${content}\n`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
process.stdout.write(content);
|
|
19
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
import { getSingle } from '../shared/args.mjs';
|
|
6
|
+
import { printTextResource } from '../shared/text-resource.mjs';
|
|
7
|
+
|
|
8
|
+
const COMMAND_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
const HELP_FILE = path.resolve(COMMAND_DIR, 'help.txt');
|
|
10
|
+
|
|
11
|
+
export function printSummarizeHelp() {
|
|
12
|
+
printTextResource(HELP_FILE);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function runSummarize(options) {
|
|
16
|
+
const file = getSingle(options, '--file', '');
|
|
17
|
+
const last = Number(getSingle(options, '--last', '0')) || 0;
|
|
18
|
+
const match = getSingle(options, '--match', '');
|
|
19
|
+
const max = Number(getSingle(options, '--max', '200')) || 200;
|
|
20
|
+
|
|
21
|
+
if (!file) {
|
|
22
|
+
printSummarizeHelp();
|
|
23
|
+
process.exit(2);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const filePath = path.resolve(file);
|
|
27
|
+
if (!fs.existsSync(filePath)) {
|
|
28
|
+
console.error(`Log file does not exist: ${filePath}`);
|
|
29
|
+
process.exit(2);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
33
|
+
const allLines = raw.split(/\r?\n/);
|
|
34
|
+
|
|
35
|
+
let lines = allLines;
|
|
36
|
+
let mode = 'all';
|
|
37
|
+
|
|
38
|
+
if (match) {
|
|
39
|
+
const pattern = new RegExp(match, 'i');
|
|
40
|
+
lines = allLines.filter((line) => pattern.test(line));
|
|
41
|
+
mode = 'match';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (last > 0) {
|
|
45
|
+
lines = lines.slice(-last);
|
|
46
|
+
mode = mode === 'match' ? 'match+last' : 'last';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (max > 0 && lines.length > max) {
|
|
50
|
+
lines = lines.slice(0, max);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const header = [
|
|
54
|
+
`# file: ${filePath}`,
|
|
55
|
+
`# totalLines: ${allLines.length}`,
|
|
56
|
+
`# selectedLines: ${lines.length}`,
|
|
57
|
+
`# mode: ${mode}`,
|
|
58
|
+
'',
|
|
59
|
+
].join('\n');
|
|
60
|
+
|
|
61
|
+
process.stdout.write(header);
|
|
62
|
+
process.stdout.write(lines.join('\n'));
|
|
63
|
+
process.stdout.write('\n');
|
|
64
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Usage:
|
|
2
|
+
agent-toolkit sync-instructions [--cwd <dir>] [--out <file>]
|
|
3
|
+
[--source <file-or-dir>] [--force] [--prune] [--dry-run]
|
|
4
|
+
|
|
5
|
+
Behavior:
|
|
6
|
+
- Syncs one or multiple .instructions.md files to target path.
|
|
7
|
+
- Defaults to <cwd>/.github/instructions/produck when --out is not provided.
|
|
8
|
+
- If --source is a directory, all *.instructions.md files in that directory are synced.
|
|
9
|
+
- If --source is omitted, built-in namespaced assets are used.
|
|
10
|
+
- Existing changed files require --force.
|
|
11
|
+
- --prune deletes unmanaged leftovers only when file contains managed marker.
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
import { getSingle, hasFlag } from '../shared/args.mjs';
|
|
6
|
+
import { loadTextResource, printTextResource } from '../shared/text-resource.mjs';
|
|
7
|
+
|
|
8
|
+
const COMMAND_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
const PACKAGE_ROOT = path.resolve(COMMAND_DIR, '../../..');
|
|
10
|
+
const PUBLISH_ASSETS_ROOT = path.resolve(PACKAGE_ROOT, 'publish-assets');
|
|
11
|
+
const PUBLISH_INSTRUCTIONS_ROOT = path.resolve(PUBLISH_ASSETS_ROOT, 'instructions');
|
|
12
|
+
const PUBLISH_NAMESPACE_ROOT = path.resolve(PUBLISH_INSTRUCTIONS_ROOT, 'produck');
|
|
13
|
+
const MANAGED_MARKER = '<!-- managed-by: @produck/agent-toolkit -->';
|
|
14
|
+
const DEFAULT_NAMESPACE_OUT_DIR = '.github/instructions/produck';
|
|
15
|
+
const USER_SPACE_ENTRYPOINT = '.github/copilot-instructions.md';
|
|
16
|
+
const HELP_FILE = path.resolve(COMMAND_DIR, 'help.txt');
|
|
17
|
+
const USER_SPACE_BOOTSTRAP_FILE = path.resolve(COMMAND_DIR, 'user-space-bootstrap.md');
|
|
18
|
+
|
|
19
|
+
export function printSyncInstructionsHelp() {
|
|
20
|
+
printTextResource(HELP_FILE);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function loadDefaultInstructionsTemplate() {
|
|
24
|
+
if (fs.existsSync(PUBLISH_NAMESPACE_ROOT)) {
|
|
25
|
+
const names = fs
|
|
26
|
+
.readdirSync(PUBLISH_NAMESPACE_ROOT)
|
|
27
|
+
.filter((name) => name.endsWith('.instructions.md'))
|
|
28
|
+
.sort((a, b) => a.localeCompare(b));
|
|
29
|
+
const entries = names.map((name) => {
|
|
30
|
+
const abs = path.resolve(PUBLISH_NAMESPACE_ROOT, name);
|
|
31
|
+
let text = fs.readFileSync(abs, 'utf8');
|
|
32
|
+
if (!text.endsWith('\n')) {
|
|
33
|
+
text = `${text}\n`;
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
fileName: name,
|
|
37
|
+
content: text,
|
|
38
|
+
sourcePath: abs,
|
|
39
|
+
};
|
|
40
|
+
});
|
|
41
|
+
return {
|
|
42
|
+
type: 'dir',
|
|
43
|
+
sourcePath: PUBLISH_NAMESPACE_ROOT,
|
|
44
|
+
entries,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
console.error('No built-in instruction assets found.');
|
|
49
|
+
console.error('Run prepack/publish to generate publish-assets, or pass --source explicitly.');
|
|
50
|
+
process.exit(2);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function readInstructionEntriesFromDirectory(sourceDir) {
|
|
54
|
+
const names = fs
|
|
55
|
+
.readdirSync(sourceDir)
|
|
56
|
+
.filter((name) => name.endsWith('.instructions.md'))
|
|
57
|
+
.sort((a, b) => a.localeCompare(b));
|
|
58
|
+
return names.map((name) => {
|
|
59
|
+
const sourcePath = path.resolve(sourceDir, name);
|
|
60
|
+
let content = fs.readFileSync(sourcePath, 'utf8');
|
|
61
|
+
if (!content.endsWith('\n')) {
|
|
62
|
+
content = `${content}\n`;
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
fileName: name,
|
|
66
|
+
content,
|
|
67
|
+
sourcePath,
|
|
68
|
+
};
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function isManagedFile(filePath) {
|
|
73
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
74
|
+
return content.includes(MANAGED_MARKER);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function buildUserSpaceBootstrapContent(namespaceDirPath, cwd) {
|
|
78
|
+
const namespaceDisplayPath = path.relative(cwd, namespaceDirPath).replace(/\\/g, '/');
|
|
79
|
+
let content = loadTextResource(USER_SPACE_BOOTSTRAP_FILE);
|
|
80
|
+
content = content.replace(/\{\{NAMESPACE_GLOB\}\}/g, `${namespaceDisplayPath}/*.instructions.md`);
|
|
81
|
+
if (!content.endsWith('\n')) {
|
|
82
|
+
content = `${content}\n`;
|
|
83
|
+
}
|
|
84
|
+
return content;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function runSyncInstructions(options) {
|
|
88
|
+
const cwd = path.resolve(getSingle(options, '--cwd', process.cwd()));
|
|
89
|
+
const outArg = getSingle(options, '--out', DEFAULT_NAMESPACE_OUT_DIR);
|
|
90
|
+
const sourceArg = getSingle(options, '--source', '');
|
|
91
|
+
const force = hasFlag(options, '--force');
|
|
92
|
+
const dryRun = hasFlag(options, '--dry-run');
|
|
93
|
+
const prune = hasFlag(options, '--prune');
|
|
94
|
+
|
|
95
|
+
if (!fs.existsSync(cwd)) {
|
|
96
|
+
console.error(`CWD does not exist: ${cwd}`);
|
|
97
|
+
process.exit(2);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const defaults = loadDefaultInstructionsTemplate();
|
|
101
|
+
let sourceType = defaults.type;
|
|
102
|
+
let sourceResolved = defaults.sourcePath;
|
|
103
|
+
let entries = defaults.entries;
|
|
104
|
+
|
|
105
|
+
if (sourceArg) {
|
|
106
|
+
const sourcePath = path.resolve(cwd, sourceArg);
|
|
107
|
+
if (!fs.existsSync(sourcePath)) {
|
|
108
|
+
console.error(`Source path does not exist: ${sourcePath}`);
|
|
109
|
+
process.exit(2);
|
|
110
|
+
}
|
|
111
|
+
const stat = fs.statSync(sourcePath);
|
|
112
|
+
if (stat.isDirectory()) {
|
|
113
|
+
sourceType = 'dir';
|
|
114
|
+
sourceResolved = sourcePath;
|
|
115
|
+
entries = readInstructionEntriesFromDirectory(sourcePath);
|
|
116
|
+
if (entries.length === 0) {
|
|
117
|
+
console.error(`No .instructions.md files in source directory: ${sourcePath}`);
|
|
118
|
+
process.exit(2);
|
|
119
|
+
}
|
|
120
|
+
} else {
|
|
121
|
+
sourceType = 'file';
|
|
122
|
+
sourceResolved = sourcePath;
|
|
123
|
+
let content = fs.readFileSync(sourcePath, 'utf8');
|
|
124
|
+
if (!content.endsWith('\n')) {
|
|
125
|
+
content = `${content}\n`;
|
|
126
|
+
}
|
|
127
|
+
entries = [
|
|
128
|
+
{
|
|
129
|
+
fileName: path.basename(sourcePath),
|
|
130
|
+
content,
|
|
131
|
+
sourcePath,
|
|
132
|
+
},
|
|
133
|
+
];
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const outPath = path.resolve(cwd, outArg);
|
|
138
|
+
const outLooksLikeFile = outArg.endsWith('.md');
|
|
139
|
+
|
|
140
|
+
if (outLooksLikeFile && entries.length > 1) {
|
|
141
|
+
console.error('Target --out is a file path but source has multiple instruction files.');
|
|
142
|
+
console.error('Use an output directory for multi-file sync.');
|
|
143
|
+
process.exit(2);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (outLooksLikeFile) {
|
|
147
|
+
const entry = entries[0];
|
|
148
|
+
const exists = fs.existsSync(outPath);
|
|
149
|
+
if (exists && !force) {
|
|
150
|
+
const current = fs.readFileSync(outPath, 'utf8');
|
|
151
|
+
if (current !== entry.content) {
|
|
152
|
+
console.error(`Target already exists: ${outPath}`);
|
|
153
|
+
console.error('Use --force to overwrite.');
|
|
154
|
+
process.exit(2);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const report = {
|
|
159
|
+
mode: 'single-file',
|
|
160
|
+
cwd,
|
|
161
|
+
sourceType,
|
|
162
|
+
source: sourceResolved,
|
|
163
|
+
outPath,
|
|
164
|
+
exists,
|
|
165
|
+
overwritten: exists && force,
|
|
166
|
+
dryRun,
|
|
167
|
+
prune: false,
|
|
168
|
+
initializedUserSpaceEntry: false,
|
|
169
|
+
userSpaceEntryPath: null,
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
if (dryRun) {
|
|
173
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
174
|
+
process.exit(0);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
178
|
+
fs.writeFileSync(outPath, entry.content, 'utf8');
|
|
179
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const outDir = outPath;
|
|
184
|
+
const planned = entries.map((entry) => {
|
|
185
|
+
return {
|
|
186
|
+
fileName: entry.fileName,
|
|
187
|
+
sourcePath: entry.sourcePath,
|
|
188
|
+
targetPath: path.resolve(outDir, entry.fileName),
|
|
189
|
+
content: entry.content,
|
|
190
|
+
};
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
const targetSet = new Set(planned.map((item) => item.targetPath));
|
|
194
|
+
const unchanged = [];
|
|
195
|
+
for (const item of planned) {
|
|
196
|
+
if (fs.existsSync(item.targetPath)) {
|
|
197
|
+
const current = fs.readFileSync(item.targetPath, 'utf8');
|
|
198
|
+
if (current === item.content) {
|
|
199
|
+
unchanged.push(item.targetPath);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const toWrite = planned.filter((item) => !unchanged.includes(item.targetPath));
|
|
205
|
+
const conflicts = toWrite.filter((item) => fs.existsSync(item.targetPath));
|
|
206
|
+
if (conflicts.length > 0 && !force) {
|
|
207
|
+
console.error('Some target files already exist and would change:');
|
|
208
|
+
for (const item of conflicts) {
|
|
209
|
+
console.error(`- ${item.targetPath}`);
|
|
210
|
+
}
|
|
211
|
+
console.error('Use --force to overwrite.');
|
|
212
|
+
process.exit(2);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const pruneDeletes = [];
|
|
216
|
+
if (prune && fs.existsSync(outDir)) {
|
|
217
|
+
const existing = fs
|
|
218
|
+
.readdirSync(outDir)
|
|
219
|
+
.filter((name) => name.endsWith('.instructions.md'))
|
|
220
|
+
.map((name) => path.resolve(outDir, name));
|
|
221
|
+
for (const existingPath of existing) {
|
|
222
|
+
if (!targetSet.has(existingPath) && isManagedFile(existingPath)) {
|
|
223
|
+
pruneDeletes.push(existingPath);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const userSpaceEntryPath = path.resolve(cwd, USER_SPACE_ENTRYPOINT);
|
|
229
|
+
const shouldInitUserSpaceEntry = !fs.existsSync(userSpaceEntryPath);
|
|
230
|
+
|
|
231
|
+
const report = {
|
|
232
|
+
mode: 'directory',
|
|
233
|
+
cwd,
|
|
234
|
+
sourceType,
|
|
235
|
+
source: sourceResolved,
|
|
236
|
+
outDir,
|
|
237
|
+
dryRun,
|
|
238
|
+
force,
|
|
239
|
+
prune,
|
|
240
|
+
initializedUserSpaceEntry: shouldInitUserSpaceEntry,
|
|
241
|
+
userSpaceEntryPath,
|
|
242
|
+
files: planned.map((item) => ({
|
|
243
|
+
fileName: item.fileName,
|
|
244
|
+
sourcePath: item.sourcePath,
|
|
245
|
+
targetPath: item.targetPath,
|
|
246
|
+
unchanged: unchanged.includes(item.targetPath),
|
|
247
|
+
})),
|
|
248
|
+
deleteFiles: pruneDeletes,
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
if (dryRun) {
|
|
252
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
253
|
+
process.exit(0);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
257
|
+
for (const item of toWrite) {
|
|
258
|
+
fs.writeFileSync(item.targetPath, item.content, 'utf8');
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (shouldInitUserSpaceEntry) {
|
|
262
|
+
fs.mkdirSync(path.dirname(userSpaceEntryPath), { recursive: true });
|
|
263
|
+
const userSpaceBootstrap = buildUserSpaceBootstrapContent(outDir, cwd);
|
|
264
|
+
fs.writeFileSync(userSpaceEntryPath, userSpaceBootstrap, 'utf8');
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
for (const filePath of pruneDeletes) {
|
|
268
|
+
fs.unlinkSync(filePath);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
272
|
+
}
|