@leejungkiin/awkit 1.4.0 → 1.4.2
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/bin/awk.js +432 -6
- package/bin/claude-generators.js +122 -0
- package/core/AGENTS.md +16 -0
- package/core/CLAUDE.md +155 -0
- package/core/GEMINI.md +44 -9
- package/package.json +1 -1
- package/skills/ai-sprite-maker/SKILL.md +81 -0
- package/skills/ai-sprite-maker/scripts/animate_sprite.py +102 -0
- package/skills/ai-sprite-maker/scripts/process_sprites.py +140 -0
- package/skills/code-review/SKILL.md +21 -33
- package/skills/lucylab-tts/SKILL.md +64 -0
- package/skills/lucylab-tts/resources/voices_library.json +908 -0
- package/skills/lucylab-tts/scripts/.env +1 -0
- package/skills/lucylab-tts/scripts/lucylab_tts.py +506 -0
- package/skills/orchestrator/SKILL.md +5 -0
- package/skills/short-maker/SKILL.md +150 -0
- package/skills/short-maker/_backup/storyboard.html +106 -0
- package/skills/short-maker/_backup/video_mixer.py +296 -0
- package/skills/short-maker/outputs/fitbite-promo/background.jpg +0 -0
- package/skills/short-maker/outputs/fitbite-promo/final/promo-final.mp4 +0 -0
- package/skills/short-maker/outputs/fitbite-promo/script.md +19 -0
- package/skills/short-maker/outputs/fitbite-promo/segments/scene-01.mp4 +0 -0
- package/skills/short-maker/outputs/fitbite-promo/segments/scene-02.mp4 +0 -0
- package/skills/short-maker/outputs/fitbite-promo/segments/scene-03.mp4 +0 -0
- package/skills/short-maker/outputs/fitbite-promo/segments/scene-04.mp4 +0 -0
- package/skills/short-maker/outputs/fitbite-promo/storyboard/scene-01.png +0 -0
- package/skills/short-maker/outputs/fitbite-promo/storyboard/scene-02.png +0 -0
- package/skills/short-maker/outputs/fitbite-promo/storyboard/scene-03.png +0 -0
- package/skills/short-maker/outputs/fitbite-promo/storyboard/scene-04.png +0 -0
- package/skills/short-maker/outputs/fitbite-promo/storyboard.html +133 -0
- package/skills/short-maker/outputs/fitbite-promo/storyboard.json +38 -0
- package/skills/short-maker/outputs/fitbite-promo/temp/merged_chroma.mp4 +0 -0
- package/skills/short-maker/outputs/fitbite-promo/temp/merged_crossfaded.mp4 +0 -0
- package/skills/short-maker/outputs/fitbite-promo/temp/ready_00.mp4 +0 -0
- package/skills/short-maker/outputs/fitbite-promo/temp/ready_01.mp4 +0 -0
- package/skills/short-maker/outputs/fitbite-promo/temp/ready_02.mp4 +0 -0
- package/skills/short-maker/outputs/fitbite-promo/temp/ready_03.mp4 +0 -0
- package/skills/short-maker/outputs/fitbite-promo/tts/manifest.json +31 -0
- package/skills/short-maker/outputs/fitbite-promo/tts/scene-01.wav +0 -0
- package/skills/short-maker/outputs/fitbite-promo/tts/scene-02.wav +0 -0
- package/skills/short-maker/outputs/fitbite-promo/tts/scene-03.wav +0 -0
- package/skills/short-maker/outputs/fitbite-promo/tts/scene-04.wav +0 -0
- package/skills/short-maker/outputs/fitbite-promo/tts_script.txt +11 -0
- package/skills/short-maker/scripts/google-flow-cli/.project-identity +41 -0
- package/skills/short-maker/scripts/google-flow-cli/.trae/rules/project_rules.md +52 -0
- package/skills/short-maker/scripts/google-flow-cli/CODEBASE.md +67 -0
- package/skills/short-maker/scripts/google-flow-cli/GoogleFlowCli.code-workspace +29 -0
- package/skills/short-maker/scripts/google-flow-cli/README.md +168 -0
- package/skills/short-maker/scripts/google-flow-cli/docs/specs/PROJECT.md +12 -0
- package/skills/short-maker/scripts/google-flow-cli/docs/specs/REQUIREMENTS.md +22 -0
- package/skills/short-maker/scripts/google-flow-cli/docs/specs/ROADMAP.md +16 -0
- package/skills/short-maker/scripts/google-flow-cli/docs/specs/TECH-SPEC.md +13 -0
- package/skills/short-maker/scripts/google-flow-cli/gflow/__init__.py +3 -0
- package/skills/short-maker/scripts/google-flow-cli/gflow/api/__init__.py +19 -0
- package/skills/short-maker/scripts/google-flow-cli/gflow/api/client.py +1921 -0
- package/skills/short-maker/scripts/google-flow-cli/gflow/api/models.py +64 -0
- package/skills/short-maker/scripts/google-flow-cli/gflow/api/rpc_ids.py +98 -0
- package/skills/short-maker/scripts/google-flow-cli/gflow/auth/__init__.py +15 -0
- package/skills/short-maker/scripts/google-flow-cli/gflow/auth/browser_auth.py +692 -0
- package/skills/short-maker/scripts/google-flow-cli/gflow/auth/humanizer.py +417 -0
- package/skills/short-maker/scripts/google-flow-cli/gflow/auth/proxy_ext.py +120 -0
- package/skills/short-maker/scripts/google-flow-cli/gflow/auth/recaptcha.py +482 -0
- package/skills/short-maker/scripts/google-flow-cli/gflow/batchexecute/__init__.py +5 -0
- package/skills/short-maker/scripts/google-flow-cli/gflow/batchexecute/client.py +414 -0
- package/skills/short-maker/scripts/google-flow-cli/gflow/cli/__init__.py +1 -0
- package/skills/short-maker/scripts/google-flow-cli/gflow/cli/main.py +1075 -0
- package/skills/short-maker/scripts/google-flow-cli/pyproject.toml +36 -0
- package/skills/short-maker/scripts/google-flow-cli/script.txt +22 -0
- package/skills/short-maker/scripts/google-flow-cli/tests/__init__.py +0 -0
- package/skills/short-maker/scripts/google-flow-cli/tests/test_batchexecute.py +113 -0
- package/skills/short-maker/scripts/google-flow-cli/tests/test_client.py +190 -0
- package/skills/short-maker/templates/aida_script.md +40 -0
- package/skills/short-maker/templates/mimic_analyzer.md +29 -0
- package/skills/single-flow-task-execution/SKILL.md +9 -6
- package/skills/skill-creator/SKILL.md +44 -0
- package/skills/spm-build-analysis/SKILL.md +92 -0
- package/skills/spm-build-analysis/references/build-optimization-sources.md +155 -0
- package/skills/spm-build-analysis/references/recommendation-format.md +85 -0
- package/skills/spm-build-analysis/references/spm-analysis-checks.md +105 -0
- package/skills/spm-build-analysis/scripts/check_spm_pins.py +118 -0
- package/skills/symphony-enforcer/SKILL.md +51 -83
- package/skills/symphony-orchestrator/SKILL.md +1 -1
- package/skills/trello-sync/SKILL.md +27 -28
- package/skills/verification-gate/SKILL.md +13 -2
- package/skills/xcode-build-benchmark/SKILL.md +88 -0
- package/skills/xcode-build-benchmark/references/benchmark-artifacts.md +94 -0
- package/skills/xcode-build-benchmark/references/benchmarking-workflow.md +67 -0
- package/skills/xcode-build-benchmark/schemas/build-benchmark.schema.json +230 -0
- package/skills/xcode-build-benchmark/scripts/benchmark_builds.py +308 -0
- package/skills/xcode-build-fixer/SKILL.md +218 -0
- package/skills/xcode-build-fixer/references/build-settings-best-practices.md +216 -0
- package/skills/xcode-build-fixer/references/fix-patterns.md +290 -0
- package/skills/xcode-build-fixer/references/recommendation-format.md +85 -0
- package/skills/xcode-build-fixer/scripts/benchmark_builds.py +308 -0
- package/skills/xcode-build-orchestrator/SKILL.md +156 -0
- package/skills/xcode-build-orchestrator/references/benchmark-artifacts.md +94 -0
- package/skills/xcode-build-orchestrator/references/build-settings-best-practices.md +216 -0
- package/skills/xcode-build-orchestrator/references/orchestration-report-template.md +143 -0
- package/skills/xcode-build-orchestrator/references/recommendation-format.md +85 -0
- package/skills/xcode-build-orchestrator/scripts/benchmark_builds.py +308 -0
- package/skills/xcode-build-orchestrator/scripts/diagnose_compilation.py +273 -0
- package/skills/xcode-build-orchestrator/scripts/generate_optimization_report.py +533 -0
- package/skills/xcode-compilation-analyzer/SKILL.md +89 -0
- package/skills/xcode-compilation-analyzer/references/build-optimization-sources.md +155 -0
- package/skills/xcode-compilation-analyzer/references/code-compilation-checks.md +106 -0
- package/skills/xcode-compilation-analyzer/references/recommendation-format.md +85 -0
- package/skills/xcode-compilation-analyzer/scripts/diagnose_compilation.py +273 -0
- package/skills/xcode-project-analyzer/SKILL.md +76 -0
- package/skills/xcode-project-analyzer/references/build-optimization-sources.md +155 -0
- package/skills/xcode-project-analyzer/references/build-settings-best-practices.md +216 -0
- package/skills/xcode-project-analyzer/references/project-audit-checks.md +101 -0
- package/skills/xcode-project-analyzer/references/recommendation-format.md +85 -0
- package/templates/project-identity/android.json +0 -10
- package/templates/project-identity/backend-nestjs.json +0 -10
- package/templates/project-identity/expo.json +0 -10
- package/templates/project-identity/ios.json +0 -10
- package/templates/project-identity/web-nextjs.json +0 -10
- package/workflows/_uncategorized/ship-to-code.md +85 -0
- package/workflows/context/codebase-sync.md +10 -87
package/bin/awk.js
CHANGED
|
@@ -36,6 +36,7 @@ const HOME = process.env.HOME || process.env.USERPROFILE;
|
|
|
36
36
|
|
|
37
37
|
const { generateClineRules, generateClineWorkflows, generateClineSkills } = require('./cline-generators');
|
|
38
38
|
const { generateCodexAgentsMd, generateCodexSkills, generateCodexAgents } = require('./codex-generators');
|
|
39
|
+
const { generateClaudeRules, generateClaudeSkills } = require('./claude-generators');
|
|
39
40
|
|
|
40
41
|
// ─── Platform Definitions ──────────────────────────────────────────────────
|
|
41
42
|
|
|
@@ -73,6 +74,15 @@ const PLATFORMS = {
|
|
|
73
74
|
agents: 'agents',
|
|
74
75
|
skills: '../.agents/skills',
|
|
75
76
|
},
|
|
77
|
+
},
|
|
78
|
+
claude: {
|
|
79
|
+
name: 'Claude Code',
|
|
80
|
+
globalRoot: process.cwd(), // Local to project
|
|
81
|
+
rulesFile: 'CLAUDE.md',
|
|
82
|
+
versionFile: '.claude/awk_version',
|
|
83
|
+
dirs: {
|
|
84
|
+
skills: '.claude/skills',
|
|
85
|
+
},
|
|
76
86
|
}
|
|
77
87
|
};
|
|
78
88
|
|
|
@@ -304,8 +314,9 @@ function cmdInstall(args = []) {
|
|
|
304
314
|
selectedPlatforms = Object.keys(PLATFORMS);
|
|
305
315
|
} else {
|
|
306
316
|
if (args.includes('--gemini') || args.includes('-g') || args.includes('antigravity')) selectedPlatforms.push('antigravity');
|
|
307
|
-
if (args.includes('--
|
|
317
|
+
if (args.includes('--cline') || args.includes('cline')) selectedPlatforms.push('cline');
|
|
308
318
|
if (args.includes('--codex') || args.includes('-x')) selectedPlatforms.push('codex');
|
|
319
|
+
if (args.includes('--claude-code') || args.includes('--claude') || args.includes('-c') || args.includes('claude')) selectedPlatforms.push('claude');
|
|
309
320
|
|
|
310
321
|
const pIdx = args.indexOf('--platform');
|
|
311
322
|
let legacyArg = null;
|
|
@@ -324,19 +335,21 @@ function cmdInstall(args = []) {
|
|
|
324
335
|
if (isUpdate) {
|
|
325
336
|
selectedPlatforms = [getActivePlatform()];
|
|
326
337
|
} else {
|
|
327
|
-
log(`${C.cyan}Select platforms to install (e.g., type "1,2", "all", or "1,2,3"):${C.reset}`);
|
|
338
|
+
log(`${C.cyan}Select platforms to install (e.g., type "1,2", "all", or "1,2,3,4"):${C.reset}`);
|
|
328
339
|
log(` 1. Gemini Code Assist (antigravity)`);
|
|
329
|
-
log(` 2.
|
|
340
|
+
log(` 2. Cline (VS Code)`);
|
|
330
341
|
log(` 3. Codex CLI (codex)`);
|
|
331
|
-
log(` 4.
|
|
332
|
-
|
|
342
|
+
log(` 4. Claude Code (.claude/)`);
|
|
343
|
+
log(` 5. All of the above`);
|
|
344
|
+
const choice = promptChoice('Choice', '5').trim().toLowerCase();
|
|
333
345
|
|
|
334
|
-
if (choice === '
|
|
346
|
+
if (choice === '5' || choice === 'all' || choice === '') {
|
|
335
347
|
selectedPlatforms = Object.keys(PLATFORMS);
|
|
336
348
|
} else {
|
|
337
349
|
if (choice.includes('1')) selectedPlatforms.push('antigravity');
|
|
338
350
|
if (choice.includes('2')) selectedPlatforms.push('cline');
|
|
339
351
|
if (choice.includes('3')) selectedPlatforms.push('codex');
|
|
352
|
+
if (choice.includes('4')) selectedPlatforms.push('claude');
|
|
340
353
|
}
|
|
341
354
|
}
|
|
342
355
|
}
|
|
@@ -391,6 +404,11 @@ function cmdInstall(args = []) {
|
|
|
391
404
|
} else if (platform === 'codex') {
|
|
392
405
|
info('Generating Codex AGENTS.md...');
|
|
393
406
|
generateCodexAgentsMd(path.join(AWK_ROOT, 'core', 'GEMINI.md'), plat.rulesFile);
|
|
407
|
+
} else if (platform === 'claude') {
|
|
408
|
+
info('Generating Claude Code CLAUDE.md...');
|
|
409
|
+
const claudeTemplateSrc = path.join(AWK_ROOT, 'core', 'CLAUDE.md');
|
|
410
|
+
const claudeRulesDest = path.join(target, plat.rulesFile);
|
|
411
|
+
generateClaudeRules(claudeTemplateSrc, claudeRulesDest);
|
|
394
412
|
}
|
|
395
413
|
|
|
396
414
|
// 3. Backup and install workflows
|
|
@@ -444,6 +462,8 @@ function cmdInstall(args = []) {
|
|
|
444
462
|
generateCodexSkills(skillsSrc, skillsDest);
|
|
445
463
|
const agentsDest = path.join(target, plat.dirs.agents);
|
|
446
464
|
generateCodexAgents(skillsSrc, agentsDest);
|
|
465
|
+
} else if (platform === 'claude') {
|
|
466
|
+
generateClaudeSkills(skillsSrc, skillsDest);
|
|
447
467
|
} else {
|
|
448
468
|
const skillCount = copyDirRecursive(skillsSrc, skillsDest);
|
|
449
469
|
ok(`${skillCount} skill files installed`);
|
|
@@ -716,6 +736,77 @@ function cmdDoctor() {
|
|
|
716
736
|
log('');
|
|
717
737
|
}
|
|
718
738
|
|
|
739
|
+
/**
|
|
740
|
+
* Handle browser-related tasks (e.g., cleaning up recordings).
|
|
741
|
+
*/
|
|
742
|
+
function cmdBrowser(args) {
|
|
743
|
+
if (args[0] !== 'clean') {
|
|
744
|
+
err('Unknown browser command. Use "awkit browser clean".');
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
const recordingsDir = path.join(TARGETS.antigravity, 'browser_recordings');
|
|
749
|
+
|
|
750
|
+
log('');
|
|
751
|
+
log(`${C.cyan}${C.bold}🧹 AWK Browser Cleanup${C.reset}`);
|
|
752
|
+
log('');
|
|
753
|
+
|
|
754
|
+
if (!fs.existsSync(recordingsDir)) {
|
|
755
|
+
ok(`No browser_recordings directory found at ${recordingsDir}. Nothing to clean.`);
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
const files = fs.readdirSync(recordingsDir).filter(f => f.endsWith('.webm') || f.endsWith('.webp') || f.endsWith('.mp4'));
|
|
760
|
+
if (files.length === 0) {
|
|
761
|
+
ok('No browser recordings found. Nothing to clean.');
|
|
762
|
+
return;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
let keepDays = 7; // default 7 days
|
|
766
|
+
const daysArgIdx = args.indexOf('--days');
|
|
767
|
+
if (daysArgIdx !== -1 && args[daysArgIdx + 1]) {
|
|
768
|
+
keepDays = parseInt(args[daysArgIdx + 1], 10);
|
|
769
|
+
} else if (args.includes('--all')) {
|
|
770
|
+
keepDays = 0;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
if (keepDays === 0) {
|
|
774
|
+
log(`Cleaning ${C.yellow}ALL${C.reset} browser recordings...`);
|
|
775
|
+
} else {
|
|
776
|
+
log(`Cleaning browser recordings older than ${C.yellow}${keepDays} days${C.reset}...`);
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
const now = Date.now();
|
|
780
|
+
const cutoff = now - (keepDays * 24 * 60 * 60 * 1000);
|
|
781
|
+
let deletedCount = 0;
|
|
782
|
+
let totalSizeFreed = 0;
|
|
783
|
+
|
|
784
|
+
for (const file of files) {
|
|
785
|
+
const filePath = path.join(recordingsDir, file);
|
|
786
|
+
try {
|
|
787
|
+
const stats = fs.statSync(filePath);
|
|
788
|
+
if (stats.mtimeMs < cutoff) {
|
|
789
|
+
totalSizeFreed += stats.size;
|
|
790
|
+
fs.unlinkSync(filePath);
|
|
791
|
+
deletedCount++;
|
|
792
|
+
dim(`Deleted: ${file}`);
|
|
793
|
+
}
|
|
794
|
+
} catch (e) {
|
|
795
|
+
warn(`Failed to process ${file}: ${e.message}`);
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
log('');
|
|
800
|
+
const sizeMb = (totalSizeFreed / (1024 * 1024)).toFixed(2);
|
|
801
|
+
if (deletedCount > 0) {
|
|
802
|
+
ok(`Cleaned ${C.green}${C.bold}${deletedCount}${C.reset} recording(s).`);
|
|
803
|
+
ok(`Freed ${C.green}${C.bold}${sizeMb} MB${C.reset} of disk space.`);
|
|
804
|
+
} else {
|
|
805
|
+
ok(`No recordings older than ${keepDays} days found. Disk space is already optimized.`);
|
|
806
|
+
}
|
|
807
|
+
log('');
|
|
808
|
+
}
|
|
809
|
+
|
|
719
810
|
/**
|
|
720
811
|
* Find a compatible Python interpreter meeting the minimum version requirement.
|
|
721
812
|
* Tries python3.13, python3.12, python3.11, python3, python in order.
|
|
@@ -1420,6 +1511,55 @@ async function cmdAdmin() {
|
|
|
1420
1511
|
}
|
|
1421
1512
|
}
|
|
1422
1513
|
|
|
1514
|
+
async function cmdRestart() {
|
|
1515
|
+
info('Đang restart service awkit (Symphony)...');
|
|
1516
|
+
try {
|
|
1517
|
+
const { execSync, spawn } = require('child_process');
|
|
1518
|
+
try {
|
|
1519
|
+
// Find and kill process on port 3100
|
|
1520
|
+
const pids = execSync('lsof -t -i:3100').toString().trim().split('\n');
|
|
1521
|
+
for (const pid of pids) {
|
|
1522
|
+
if (pid) {
|
|
1523
|
+
process.kill(parseInt(pid, 10), 'SIGTERM');
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
info('Đã dừng service hiện tại.');
|
|
1527
|
+
} catch (e) {
|
|
1528
|
+
// Probably no process running on port 3100
|
|
1529
|
+
dim('Không tìm thấy service đang chạy.');
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
1533
|
+
|
|
1534
|
+
// Auto-build production bundle so code changes take effect
|
|
1535
|
+
const symphonyDir = path.join(AWK_ROOT, '..', 'symphony');
|
|
1536
|
+
if (fs.existsSync(path.join(symphonyDir, 'package.json'))) {
|
|
1537
|
+
info('Đang build production bundle...');
|
|
1538
|
+
try {
|
|
1539
|
+
execSync('npm run build', { cwd: symphonyDir, stdio: 'pipe' });
|
|
1540
|
+
log(`${C.green}✅ Build thành công!${C.reset}`);
|
|
1541
|
+
} catch (buildErr) {
|
|
1542
|
+
warn('Build thất bại, sử dụng bundle cũ.');
|
|
1543
|
+
dim(buildErr.message?.slice(0, 200));
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
info('Đang khởi động lại service ngầm...');
|
|
1548
|
+
const child = spawn('symphony', ['start'], {
|
|
1549
|
+
detached: true,
|
|
1550
|
+
stdio: 'ignore'
|
|
1551
|
+
});
|
|
1552
|
+
child.unref();
|
|
1553
|
+
|
|
1554
|
+
info('Vui lòng đợi 3 giây để service khởi động...');
|
|
1555
|
+
await new Promise(r => setTimeout(r, 3000));
|
|
1556
|
+
|
|
1557
|
+
log(`${C.green}✅ Restart thành công!${C.reset}`);
|
|
1558
|
+
} catch (e) {
|
|
1559
|
+
err('Lỗi khi restart service: ' + e.message);
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1423
1563
|
function cmdHelp() {
|
|
1424
1564
|
const line = `${C.gray}${'─'.repeat(56)}${C.reset}`;
|
|
1425
1565
|
log('');
|
|
@@ -1447,6 +1587,15 @@ function cmdHelp() {
|
|
|
1447
1587
|
log(` ${C.gray} CODEBASE.md, .symphony/ (Symphony task DB)${C.reset}`);
|
|
1448
1588
|
log('');
|
|
1449
1589
|
|
|
1590
|
+
// Maintenance
|
|
1591
|
+
log(`${C.bold}🧹 Maintenance${C.reset}`);
|
|
1592
|
+
log(line);
|
|
1593
|
+
log(` ${C.green}serve${C.reset} [dir] [-p <port>] Start local HTTP server for assets in CWD`);
|
|
1594
|
+
log(` ${C.green}browser clean${C.reset} Clean browser recordings`);
|
|
1595
|
+
log(` ${C.gray} --days <N>${C.reset} Keep recordings from last N days (default: 7)`);
|
|
1596
|
+
log(` ${C.gray} --all${C.reset} Delete all recordings`);
|
|
1597
|
+
log('');
|
|
1598
|
+
|
|
1450
1599
|
// Sync
|
|
1451
1600
|
log(`${C.bold}🔄 Sync${C.reset}`);
|
|
1452
1601
|
log(line);
|
|
@@ -2219,6 +2368,178 @@ function cmdTelegram(args) {
|
|
|
2219
2368
|
}
|
|
2220
2369
|
}
|
|
2221
2370
|
|
|
2371
|
+
// ─── Credentials Management ───────────────────────────────────────────────────
|
|
2372
|
+
|
|
2373
|
+
const CREDENTIALS_CONFIG_PATH = path.join(TARGETS.antigravity, '.credentials.json');
|
|
2374
|
+
|
|
2375
|
+
function credentialsLoad() {
|
|
2376
|
+
if (!fs.existsSync(CREDENTIALS_CONFIG_PATH)) return {};
|
|
2377
|
+
try {
|
|
2378
|
+
return JSON.parse(fs.readFileSync(CREDENTIALS_CONFIG_PATH, 'utf8'));
|
|
2379
|
+
} catch (_) {
|
|
2380
|
+
return {};
|
|
2381
|
+
}
|
|
2382
|
+
}
|
|
2383
|
+
|
|
2384
|
+
function credentialsSave(config) {
|
|
2385
|
+
const dir = path.dirname(CREDENTIALS_CONFIG_PATH);
|
|
2386
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
2387
|
+
fs.writeFileSync(CREDENTIALS_CONFIG_PATH, JSON.stringify(config, null, 2) + '\n');
|
|
2388
|
+
}
|
|
2389
|
+
|
|
2390
|
+
function credentialsHelp() {
|
|
2391
|
+
log('');
|
|
2392
|
+
log(`${C.cyan}${C.bold}🔑 Credentials Commands${C.reset}`);
|
|
2393
|
+
log('');
|
|
2394
|
+
log(` ${C.green}awkit credentials list${C.reset} List all stored credentials`);
|
|
2395
|
+
log(` ${C.green}awkit credentials set${C.reset} <key> <value> Set a credential`);
|
|
2396
|
+
log(` ${C.green}awkit credentials get${C.reset} <key> Get a credential value`);
|
|
2397
|
+
log(` ${C.green}awkit credentials remove${C.reset} <key> Remove a credential`);
|
|
2398
|
+
log(` ${C.green}awkit credentials setup${C.reset} Interactive setup wizard`);
|
|
2399
|
+
log('');
|
|
2400
|
+
log(` ${C.gray}Known keys: gemini_api_key, lucylab_bearer${C.reset}`);
|
|
2401
|
+
log(` ${C.gray}Config: ${CREDENTIALS_CONFIG_PATH}${C.reset}`);
|
|
2402
|
+
log('');
|
|
2403
|
+
}
|
|
2404
|
+
|
|
2405
|
+
async function credentialsSetup() {
|
|
2406
|
+
log('');
|
|
2407
|
+
log(`${C.cyan}${C.bold}🔑 API Credentials Setup${C.reset}`);
|
|
2408
|
+
log('');
|
|
2409
|
+
log(`${C.gray} Credentials are stored in: ${CREDENTIALS_CONFIG_PATH}${C.reset}`);
|
|
2410
|
+
log(`${C.gray} Used by Short Maker, Symphony Admin, and other services.${C.reset}`);
|
|
2411
|
+
log('');
|
|
2412
|
+
|
|
2413
|
+
const readline = require('readline');
|
|
2414
|
+
const rl = readline.createInterface({
|
|
2415
|
+
input: process.stdin,
|
|
2416
|
+
output: process.stdout
|
|
2417
|
+
});
|
|
2418
|
+
|
|
2419
|
+
const question = (query) => new Promise(resolve => rl.question(query, resolve));
|
|
2420
|
+
const sanitize = (s) => s.trim().replace(/^bearer\s+/i, '').replace(/\s+/g, '');
|
|
2421
|
+
|
|
2422
|
+
const config = credentialsLoad();
|
|
2423
|
+
|
|
2424
|
+
try {
|
|
2425
|
+
// Gemini API Key
|
|
2426
|
+
log(`${C.gray} Get your key at: https://aistudio.google.com/apikey${C.reset}`);
|
|
2427
|
+
const geminiKey = sanitize(await question(` ${C.yellow}Gemini API Key${config.gemini_api_key ? ` [${config.gemini_api_key.slice(0, 8)}...]` : ''}: ${C.reset}`));
|
|
2428
|
+
if (geminiKey) {
|
|
2429
|
+
config.gemini_api_key = geminiKey;
|
|
2430
|
+
ok('Gemini API Key saved');
|
|
2431
|
+
} else if (config.gemini_api_key) {
|
|
2432
|
+
dim('Kept existing Gemini API Key');
|
|
2433
|
+
}
|
|
2434
|
+
|
|
2435
|
+
log('');
|
|
2436
|
+
|
|
2437
|
+
// LucyLab Bearer
|
|
2438
|
+
log(`${C.gray} LucyLab TTS bearer token for voice generation${C.reset}`);
|
|
2439
|
+
const lucylabToken = sanitize(await question(` ${C.yellow}LucyLab Bearer${config.lucylab_bearer ? ` [${config.lucylab_bearer.slice(0, 8)}...]` : ''}: ${C.reset}`));
|
|
2440
|
+
if (lucylabToken) {
|
|
2441
|
+
config.lucylab_bearer = lucylabToken;
|
|
2442
|
+
ok('LucyLab Bearer saved');
|
|
2443
|
+
} else if (config.lucylab_bearer) {
|
|
2444
|
+
dim('Kept existing LucyLab Bearer');
|
|
2445
|
+
}
|
|
2446
|
+
|
|
2447
|
+
credentialsSave(config);
|
|
2448
|
+
log('');
|
|
2449
|
+
ok(`Credentials saved to ${CREDENTIALS_CONFIG_PATH}`);
|
|
2450
|
+
log('');
|
|
2451
|
+
} catch (e) {
|
|
2452
|
+
warn(`Failed to setup credentials: ${e.message}`);
|
|
2453
|
+
} finally {
|
|
2454
|
+
rl.close();
|
|
2455
|
+
}
|
|
2456
|
+
}
|
|
2457
|
+
|
|
2458
|
+
function cmdCredentials(args) {
|
|
2459
|
+
const subCmd = args[0];
|
|
2460
|
+
const key = args[1];
|
|
2461
|
+
const value = args.slice(2).join(' ');
|
|
2462
|
+
|
|
2463
|
+
switch (subCmd) {
|
|
2464
|
+
case 'list': {
|
|
2465
|
+
const config = credentialsLoad();
|
|
2466
|
+
const keys = Object.keys(config);
|
|
2467
|
+
if (keys.length === 0) {
|
|
2468
|
+
warn('No credentials stored. Run "awkit credentials setup" to configure.');
|
|
2469
|
+
return;
|
|
2470
|
+
}
|
|
2471
|
+
log('');
|
|
2472
|
+
log(`${C.cyan}${C.bold}🔑 Stored Credentials${C.reset}`);
|
|
2473
|
+
log('');
|
|
2474
|
+
for (const k of keys) {
|
|
2475
|
+
const val = config[k];
|
|
2476
|
+
const masked = val ? `${val.slice(0, 8)}${'•'.repeat(Math.max(0, val.length - 8))}` : '(empty)';
|
|
2477
|
+
log(` ${C.green}${k}${C.reset} = ${C.gray}${masked}${C.reset}`);
|
|
2478
|
+
}
|
|
2479
|
+
log('');
|
|
2480
|
+
dim(`Config: ${CREDENTIALS_CONFIG_PATH}`);
|
|
2481
|
+
log('');
|
|
2482
|
+
break;
|
|
2483
|
+
}
|
|
2484
|
+
|
|
2485
|
+
case 'set': {
|
|
2486
|
+
let valueToSet = value;
|
|
2487
|
+
if (valueToSet) {
|
|
2488
|
+
valueToSet = valueToSet.trim().replace(/^bearer\s+/i, '').replace(/\s+/g, '');
|
|
2489
|
+
}
|
|
2490
|
+
if (!key || !valueToSet) {
|
|
2491
|
+
err('Usage: awkit credentials set <key> <value>');
|
|
2492
|
+
dim('Example: awkit credentials set gemini_api_key AIzaSy...');
|
|
2493
|
+
return;
|
|
2494
|
+
}
|
|
2495
|
+
const config = credentialsLoad();
|
|
2496
|
+
config[key] = valueToSet;
|
|
2497
|
+
credentialsSave(config);
|
|
2498
|
+
ok(`${key} saved ✅`);
|
|
2499
|
+
break;
|
|
2500
|
+
}
|
|
2501
|
+
|
|
2502
|
+
case 'get': {
|
|
2503
|
+
if (!key) {
|
|
2504
|
+
err('Usage: awkit credentials get <key>');
|
|
2505
|
+
return;
|
|
2506
|
+
}
|
|
2507
|
+
const config = credentialsLoad();
|
|
2508
|
+
if (config[key]) {
|
|
2509
|
+
log(config[key]);
|
|
2510
|
+
} else {
|
|
2511
|
+
warn(`Key "${key}" not found.`);
|
|
2512
|
+
}
|
|
2513
|
+
break;
|
|
2514
|
+
}
|
|
2515
|
+
|
|
2516
|
+
case 'remove':
|
|
2517
|
+
case 'delete': {
|
|
2518
|
+
if (!key) {
|
|
2519
|
+
err('Usage: awkit credentials remove <key>');
|
|
2520
|
+
return;
|
|
2521
|
+
}
|
|
2522
|
+
const config = credentialsLoad();
|
|
2523
|
+
if (config[key]) {
|
|
2524
|
+
delete config[key];
|
|
2525
|
+
credentialsSave(config);
|
|
2526
|
+
ok(`${key} removed`);
|
|
2527
|
+
} else {
|
|
2528
|
+
warn(`Key "${key}" not found.`);
|
|
2529
|
+
}
|
|
2530
|
+
break;
|
|
2531
|
+
}
|
|
2532
|
+
|
|
2533
|
+
case 'setup':
|
|
2534
|
+
credentialsSetup();
|
|
2535
|
+
break;
|
|
2536
|
+
|
|
2537
|
+
default:
|
|
2538
|
+
credentialsHelp();
|
|
2539
|
+
break;
|
|
2540
|
+
}
|
|
2541
|
+
}
|
|
2542
|
+
|
|
2222
2543
|
// ─── Trello Integration ───────────────────────────────────────────────────────
|
|
2223
2544
|
|
|
2224
2545
|
/**
|
|
@@ -2490,6 +2811,98 @@ function checkAutoUpdate() {
|
|
|
2490
2811
|
}
|
|
2491
2812
|
}
|
|
2492
2813
|
|
|
2814
|
+
// ─── Native HTTP Server ───────────────────────────────────────────────────────
|
|
2815
|
+
|
|
2816
|
+
function cmdServe(args) {
|
|
2817
|
+
const http = require('http');
|
|
2818
|
+
|
|
2819
|
+
let port = 8080;
|
|
2820
|
+
let serveDir = process.cwd();
|
|
2821
|
+
|
|
2822
|
+
for (let i = 0; i < args.length; i++) {
|
|
2823
|
+
if (args[i] === '--port' || args[i] === '-p') {
|
|
2824
|
+
port = parseInt(args[++i], 10) || 8080;
|
|
2825
|
+
} else if (!args[i].startsWith('-')) {
|
|
2826
|
+
serveDir = path.resolve(process.cwd(), args[i]);
|
|
2827
|
+
}
|
|
2828
|
+
}
|
|
2829
|
+
|
|
2830
|
+
if (!fs.existsSync(serveDir)) {
|
|
2831
|
+
err(`Directory not found: ${serveDir}`);
|
|
2832
|
+
return;
|
|
2833
|
+
}
|
|
2834
|
+
|
|
2835
|
+
const mimeTypes = {
|
|
2836
|
+
'.html': 'text/html',
|
|
2837
|
+
'.js': 'text/javascript',
|
|
2838
|
+
'.css': 'text/css',
|
|
2839
|
+
'.json': 'application/json',
|
|
2840
|
+
'.png': 'image/png',
|
|
2841
|
+
'.jpg': 'image/jpeg',
|
|
2842
|
+
'.jpeg': 'image/jpeg',
|
|
2843
|
+
'.gif': 'image/gif',
|
|
2844
|
+
'.svg': 'image/svg+xml',
|
|
2845
|
+
'.wav': 'audio/wav',
|
|
2846
|
+
'.mp4': 'video/mp4',
|
|
2847
|
+
'.woff': 'application/font-woff',
|
|
2848
|
+
'.ttf': 'application/font-ttf',
|
|
2849
|
+
'.eot': 'application/vnd.ms-fontobject',
|
|
2850
|
+
'.otf': 'application/font-otf',
|
|
2851
|
+
'.wasm': 'application/wasm'
|
|
2852
|
+
};
|
|
2853
|
+
|
|
2854
|
+
const server = http.createServer((request, response) => {
|
|
2855
|
+
response.setHeader('Access-Control-Allow-Origin', '*');
|
|
2856
|
+
response.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
|
|
2857
|
+
response.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
2858
|
+
|
|
2859
|
+
if (request.method === 'OPTIONS') {
|
|
2860
|
+
response.writeHead(200);
|
|
2861
|
+
response.end();
|
|
2862
|
+
return;
|
|
2863
|
+
}
|
|
2864
|
+
|
|
2865
|
+
let filePath = '.' + request.url.split('?')[0];
|
|
2866
|
+
if (filePath === './') {
|
|
2867
|
+
filePath = './index.html';
|
|
2868
|
+
}
|
|
2869
|
+
|
|
2870
|
+
const absPath = path.join(serveDir, filePath);
|
|
2871
|
+
const extname = String(path.extname(absPath)).toLowerCase();
|
|
2872
|
+
const contentType = mimeTypes[extname] || 'application/octet-stream';
|
|
2873
|
+
|
|
2874
|
+
fs.readFile(absPath, (error, content) => {
|
|
2875
|
+
if (error) {
|
|
2876
|
+
if (error.code === 'ENOENT') {
|
|
2877
|
+
response.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
2878
|
+
response.end('404 Not Found', 'utf-8');
|
|
2879
|
+
} else {
|
|
2880
|
+
response.writeHead(500, { 'Content-Type': 'text/plain' });
|
|
2881
|
+
response.end('500 Internal Server Error: ' + error.code, 'utf-8');
|
|
2882
|
+
}
|
|
2883
|
+
} else {
|
|
2884
|
+
response.writeHead(200, { 'Content-Type': contentType });
|
|
2885
|
+
response.end(content, 'utf-8');
|
|
2886
|
+
}
|
|
2887
|
+
});
|
|
2888
|
+
});
|
|
2889
|
+
|
|
2890
|
+
server.listen(port, '0.0.0.0', () => {
|
|
2891
|
+
log('');
|
|
2892
|
+
log(`${C.cyan}${C.bold}🚀 awkit serve running at:${C.reset}`);
|
|
2893
|
+
log(`${C.green} http://localhost:${port}${C.reset}`);
|
|
2894
|
+
dim(`Serving directory: ${serveDir}`);
|
|
2895
|
+
dim(`Press Ctrl+C to stop`);
|
|
2896
|
+
log('');
|
|
2897
|
+
}).on('error', (e) => {
|
|
2898
|
+
if (e.code === 'EADDRINUSE') {
|
|
2899
|
+
err(`Port ${port} is already in use. Try a different port with --port <number>`);
|
|
2900
|
+
} else {
|
|
2901
|
+
err(`Server error: ${e.message}`);
|
|
2902
|
+
}
|
|
2903
|
+
});
|
|
2904
|
+
}
|
|
2905
|
+
|
|
2493
2906
|
// ─── Main ────────────────────────────────────────────────────────────────────
|
|
2494
2907
|
|
|
2495
2908
|
// Check for updates (max once per day) before continuing
|
|
@@ -2523,6 +2936,9 @@ const [, , command, ...args] = process.argv;
|
|
|
2523
2936
|
case 'doctor':
|
|
2524
2937
|
cmdDoctor();
|
|
2525
2938
|
break;
|
|
2939
|
+
case 'browser':
|
|
2940
|
+
cmdBrowser(args);
|
|
2941
|
+
break;
|
|
2526
2942
|
case 'enable-pack':
|
|
2527
2943
|
cmdEnablePack(args[0]);
|
|
2528
2944
|
break;
|
|
@@ -2547,9 +2963,19 @@ const [, , command, ...args] = process.argv;
|
|
|
2547
2963
|
case 'telegram':
|
|
2548
2964
|
cmdTelegram(args);
|
|
2549
2965
|
break;
|
|
2966
|
+
case 'credentials':
|
|
2967
|
+
case 'creds':
|
|
2968
|
+
cmdCredentials(args);
|
|
2969
|
+
break;
|
|
2970
|
+
case 'serve':
|
|
2971
|
+
cmdServe(args);
|
|
2972
|
+
break;
|
|
2550
2973
|
case 'admin':
|
|
2551
2974
|
cmdAdmin();
|
|
2552
2975
|
break;
|
|
2976
|
+
case 'restart':
|
|
2977
|
+
await cmdRestart();
|
|
2978
|
+
break;
|
|
2553
2979
|
case 'help':
|
|
2554
2980
|
case '--help':
|
|
2555
2981
|
case '-h':
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Generates Claude Code CLAUDE.md from core/GEMINI.md template.
|
|
6
|
+
* Replaces Antigravity-specific paths and references with Claude Code equivalents.
|
|
7
|
+
* @param {string} sourcePath Path to core/CLAUDE.md (pre-adapted template)
|
|
8
|
+
* @param {string} destPath Destination path (e.g. <project>/CLAUDE.md)
|
|
9
|
+
*/
|
|
10
|
+
function generateClaudeRules(sourcePath, destPath) {
|
|
11
|
+
if (!fs.existsSync(sourcePath)) {
|
|
12
|
+
console.log(`⚠️ Template not found: ${sourcePath}`);
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const content = fs.readFileSync(sourcePath, 'utf8');
|
|
17
|
+
|
|
18
|
+
fs.mkdirSync(path.dirname(destPath), { recursive: true });
|
|
19
|
+
fs.writeFileSync(destPath, content.trim() + '\n');
|
|
20
|
+
console.log(`✅ Claude Code CLAUDE.md generated at: ${destPath}`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Copies skill directories to Claude Code .claude/skills/ structure.
|
|
25
|
+
* Preserves full directory structure: <skill-name>/SKILL.md + scripts/ + templates/
|
|
26
|
+
* Injects YAML frontmatter if missing.
|
|
27
|
+
*/
|
|
28
|
+
function generateClaudeSkills(srcDir, destDir) {
|
|
29
|
+
if (!fs.existsSync(srcDir)) return;
|
|
30
|
+
|
|
31
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
32
|
+
const skills = fs.readdirSync(srcDir);
|
|
33
|
+
|
|
34
|
+
let count = 0;
|
|
35
|
+
for (const skill of skills) {
|
|
36
|
+
const skillSrcDir = path.join(srcDir, skill);
|
|
37
|
+
if (!fs.statSync(skillSrcDir).isDirectory()) continue;
|
|
38
|
+
if (skill === '.DS_Store') continue;
|
|
39
|
+
|
|
40
|
+
// Skip non-skill directories (schemas, skills, workflows nested dirs)
|
|
41
|
+
const skillFile = path.join(skillSrcDir, 'SKILL.md');
|
|
42
|
+
if (!fs.existsSync(skillFile)) continue;
|
|
43
|
+
|
|
44
|
+
const skillDestDir = path.join(destDir, skill);
|
|
45
|
+
const fileCount = copySkillDir(skillSrcDir, skillDestDir);
|
|
46
|
+
|
|
47
|
+
// Ensure SKILL.md has YAML frontmatter
|
|
48
|
+
ensureFrontmatter(path.join(skillDestDir, 'SKILL.md'), skill);
|
|
49
|
+
|
|
50
|
+
count++;
|
|
51
|
+
}
|
|
52
|
+
console.log(`✅ Generated ${count} Claude Code skills in ${destDir}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Recursively copies a skill directory preserving structure.
|
|
57
|
+
*/
|
|
58
|
+
function copySkillDir(src, dest) {
|
|
59
|
+
if (!fs.existsSync(src)) return 0;
|
|
60
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
61
|
+
|
|
62
|
+
let count = 0;
|
|
63
|
+
const entries = fs.readdirSync(src, { withFileTypes: true });
|
|
64
|
+
|
|
65
|
+
for (const entry of entries) {
|
|
66
|
+
if (entry.name === '.DS_Store') continue;
|
|
67
|
+
if (entry.name === '__pycache__') continue;
|
|
68
|
+
|
|
69
|
+
const srcPath = path.join(src, entry.name);
|
|
70
|
+
const destPath = path.join(dest, entry.name);
|
|
71
|
+
|
|
72
|
+
if (entry.isDirectory()) {
|
|
73
|
+
count += copySkillDir(srcPath, destPath);
|
|
74
|
+
} else {
|
|
75
|
+
fs.copyFileSync(srcPath, destPath);
|
|
76
|
+
count++;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return count;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Ensures a SKILL.md file has proper YAML frontmatter.
|
|
84
|
+
* If missing, injects name and description extracted from content.
|
|
85
|
+
*/
|
|
86
|
+
function ensureFrontmatter(filePath, skillName) {
|
|
87
|
+
if (!fs.existsSync(filePath)) return;
|
|
88
|
+
|
|
89
|
+
let content = fs.readFileSync(filePath, 'utf8');
|
|
90
|
+
|
|
91
|
+
// Already has frontmatter
|
|
92
|
+
if (content.startsWith('---\n')) {
|
|
93
|
+
// Ensure 'name' field exists
|
|
94
|
+
if (!content.match(/^name:/m)) {
|
|
95
|
+
content = content.replace('---\n', `---\nname: ${skillName}\n`);
|
|
96
|
+
fs.writeFileSync(filePath, content);
|
|
97
|
+
}
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Extract description from first line or heading
|
|
102
|
+
let description = `Antigravity skill: ${skillName}`;
|
|
103
|
+
const descMatch = content.match(/^#\s+(.+)/m);
|
|
104
|
+
if (descMatch) {
|
|
105
|
+
description = descMatch[1].replace(/[`*_]/g, '').trim();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Also try to extract from YAML-like description in body
|
|
109
|
+
const bodyDescMatch = content.match(/^description:\s*(.+)/m);
|
|
110
|
+
if (bodyDescMatch) {
|
|
111
|
+
description = bodyDescMatch[1].trim();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const frontmatter = `---\nname: ${skillName}\ndescription: "${description}"\n---\n`;
|
|
115
|
+
content = frontmatter + content;
|
|
116
|
+
fs.writeFileSync(filePath, content);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
module.exports = {
|
|
120
|
+
generateClaudeRules,
|
|
121
|
+
generateClaudeSkills,
|
|
122
|
+
};
|
package/core/AGENTS.md
CHANGED
|
@@ -36,3 +36,19 @@ symphony_status # Check system status
|
|
|
36
36
|
- NEVER stop before pushing — that leaves work stranded locally
|
|
37
37
|
- NEVER say "ready to push when you are" — YOU must push
|
|
38
38
|
- If push fails, resolve and retry until it succeeds
|
|
39
|
+
|
|
40
|
+
## AUTO-COMMIT RULE
|
|
41
|
+
|
|
42
|
+
**After a successful build (0 errors)**, commit immediately — do NOT wait for session end.
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
git add -A
|
|
46
|
+
git commit -m "fix: <concise description>" # conventional commit format
|
|
47
|
+
git push # non-force push, safe to auto-run
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
**Rules:**
|
|
51
|
+
- Commit NGAY sau build 0 errors — không gom lại cuối session.
|
|
52
|
+
- `git push` (non-force) được phép chạy tự động.
|
|
53
|
+
- Push fail → `git pull --rebase && git push` (retry 1 lần).
|
|
54
|
+
- Vẫn fail → báo user, KHÔNG force push.
|