aiwcli 0.10.0 → 0.10.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/dist/commands/clean.js +1 -0
- package/dist/commands/clear.d.ts +14 -2
- package/dist/commands/clear.js +298 -58
- package/dist/lib/gitignore-manager.d.ts +9 -0
- package/dist/lib/gitignore-manager.js +121 -0
- package/dist/templates/_shared/hooks/__pycache__/session_end.cpython-313.pyc +0 -0
- package/dist/templates/_shared/hooks/__pycache__/session_start.cpython-313.pyc +0 -0
- package/dist/templates/_shared/hooks/__pycache__/user_prompt_submit.cpython-313.pyc +0 -0
- package/dist/templates/_shared/hooks/session_end.py +45 -26
- package/dist/templates/_shared/hooks/session_start.py +21 -4
- package/dist/templates/_shared/hooks/user_prompt_submit.py +0 -34
- package/dist/templates/_shared/lib/base/hook_utils.py +9 -1
- package/dist/templates/_shared/lib/context/__pycache__/context_formatter.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/context/__pycache__/context_selector.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/context/__pycache__/context_store.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/context/context_formatter.py +1 -0
- package/dist/templates/_shared/lib/context/context_selector.py +25 -8
- package/dist/templates/_shared/lib/context/context_store.py +22 -5
- package/dist/templates/_shared/lib/templates/__pycache__/plan_context.cpython-313.pyc +0 -0
- package/dist/templates/_shared/lib/templates/plan_context.py +22 -0
- package/dist/templates/_shared/scripts/__pycache__/save_handoff.cpython-313.pyc +0 -0
- package/dist/templates/_shared/scripts/save_handoff.py +2 -10
- package/dist/templates/cc-native/.claude/settings.json +2 -12
- package/dist/templates/cc-native/_cc-native/hooks/CLAUDE.md +6 -1
- package/dist/templates/cc-native/_cc-native/hooks/__pycache__/add_plan_context.cpython-313.pyc +0 -0
- package/dist/templates/cc-native/_cc-native/hooks/add_plan_context.py +57 -72
- package/oclif.manifest.json +17 -2
- package/package.json +1 -1
- package/dist/templates/cc-native/_cc-native/hooks/plan_accepted.py +0 -127
package/dist/commands/clean.js
CHANGED
|
@@ -45,6 +45,7 @@ export default class CleanCommand extends BaseCommand {
|
|
|
45
45
|
async run() {
|
|
46
46
|
const { flags } = await this.parse(CleanCommand);
|
|
47
47
|
const targetDir = process.cwd();
|
|
48
|
+
this.logWarning("'aiw clean' is deprecated. Use 'aiw clear --output' instead.");
|
|
48
49
|
// Validate: require either --method or --all
|
|
49
50
|
if (!flags.method && !flags.all) {
|
|
50
51
|
this.error('Either --method or --all is required.', {
|
package/dist/commands/clear.d.ts
CHANGED
|
@@ -8,6 +8,7 @@ export default class ClearCommand extends BaseCommand {
|
|
|
8
8
|
static flags: {
|
|
9
9
|
'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
10
10
|
force: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
11
|
+
output: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
11
12
|
template: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
12
13
|
debug: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
13
14
|
help: import("@oclif/core/interfaces").BooleanFlag<void>;
|
|
@@ -22,8 +23,11 @@ export default class ClearCommand extends BaseCommand {
|
|
|
22
23
|
*/
|
|
23
24
|
private extractMethodNames;
|
|
24
25
|
/**
|
|
25
|
-
* Find all IDE method folders
|
|
26
|
-
*
|
|
26
|
+
* Find all IDE method folders by scanning subdirectories of each IDE root
|
|
27
|
+
* for children matching installed method names.
|
|
28
|
+
*
|
|
29
|
+
* For example, finds .claude/commands/cc-native/, .claude/skills/cc-native/,
|
|
30
|
+
* .windsurf/workflows/cc-native/ — without hardcoding which subdirectories exist.
|
|
27
31
|
*
|
|
28
32
|
* @param targetDir - Directory to search in
|
|
29
33
|
* @param template - Optional template/method name to filter by
|
|
@@ -48,4 +52,12 @@ export default class ClearCommand extends BaseCommand {
|
|
|
48
52
|
* @returns Array of workflow folder paths
|
|
49
53
|
*/
|
|
50
54
|
private findWorkflowFolders;
|
|
55
|
+
/**
|
|
56
|
+
* Clean runtime output artifacts from _output/ at project root.
|
|
57
|
+
* Handles temp files, cache files, log rotation, and archive cleanup.
|
|
58
|
+
*
|
|
59
|
+
* @param targetDir - Project root directory
|
|
60
|
+
* @param flags - Command flags (dry-run, force)
|
|
61
|
+
*/
|
|
62
|
+
private cleanRuntimeOutput;
|
|
51
63
|
}
|
package/dist/commands/clear.js
CHANGED
|
@@ -3,6 +3,8 @@ import { join } from 'node:path';
|
|
|
3
3
|
import { confirm } from '@inquirer/prompts';
|
|
4
4
|
import { Flags } from '@oclif/core';
|
|
5
5
|
import BaseCommand from '../lib/base-command.js';
|
|
6
|
+
import { pruneGitignoreStaleEntries } from '../lib/gitignore-manager.js';
|
|
7
|
+
import { pathExists } from '../lib/paths.js';
|
|
6
8
|
import { EXIT_CODES } from '../types/exit-codes.js';
|
|
7
9
|
/**
|
|
8
10
|
* Container folder for method-specific files
|
|
@@ -15,20 +17,59 @@ const AIWCLI_CONTAINER = '.aiwcli';
|
|
|
15
17
|
*/
|
|
16
18
|
const OUTPUT_FOLDER_NAME = '_output';
|
|
17
19
|
/**
|
|
18
|
-
* IDE configuration folder names and
|
|
20
|
+
* IDE configuration folder names and settings file locations.
|
|
21
|
+
* Method subfolders are discovered dynamically via disk scanning.
|
|
19
22
|
*/
|
|
20
23
|
const IDE_FOLDERS = {
|
|
21
24
|
claude: {
|
|
22
25
|
root: '.claude',
|
|
23
|
-
methodSubfolders: ['commands', 'skills', 'agents'],
|
|
24
26
|
settingsFile: 'settings.json',
|
|
25
27
|
},
|
|
26
28
|
windsurf: {
|
|
27
29
|
root: '.windsurf',
|
|
28
|
-
methodSubfolders: ['workflows'],
|
|
29
30
|
settingsFile: 'hooks.json',
|
|
30
31
|
},
|
|
31
32
|
};
|
|
33
|
+
/**
|
|
34
|
+
* Get the set of installed method names by combining the settings.json registry
|
|
35
|
+
* with disk scan of .aiwcli/_* directories.
|
|
36
|
+
*
|
|
37
|
+
* @param targetDir - Directory containing the .aiwcli container
|
|
38
|
+
* @returns Set of method names (e.g., 'cc-native', 'bmad')
|
|
39
|
+
*/
|
|
40
|
+
async function getInstalledMethodNames(targetDir) {
|
|
41
|
+
const methods = new Set();
|
|
42
|
+
// Source 1: settings.json methods registry
|
|
43
|
+
for (const ide of Object.values(IDE_FOLDERS)) {
|
|
44
|
+
const settingsPath = join(targetDir, ide.root, ide.settingsFile);
|
|
45
|
+
try {
|
|
46
|
+
const content = await fs.readFile(settingsPath, 'utf8');
|
|
47
|
+
const settings = JSON.parse(content);
|
|
48
|
+
if (settings.methods && typeof settings.methods === 'object') {
|
|
49
|
+
for (const method of Object.keys(settings.methods)) {
|
|
50
|
+
methods.add(method);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// Settings file doesn't exist or can't be parsed
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// Source 2: disk scan of .aiwcli/_* directories
|
|
59
|
+
const containerDir = join(targetDir, AIWCLI_CONTAINER);
|
|
60
|
+
try {
|
|
61
|
+
const entries = await fs.readdir(containerDir, { withFileTypes: true });
|
|
62
|
+
for (const entry of entries) {
|
|
63
|
+
if (entry.isDirectory() && entry.name.startsWith('_') && entry.name !== OUTPUT_FOLDER_NAME) {
|
|
64
|
+
methods.add(entry.name.slice(1)); // strip leading underscore
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// Container doesn't exist
|
|
70
|
+
}
|
|
71
|
+
return methods;
|
|
72
|
+
}
|
|
32
73
|
/**
|
|
33
74
|
* AIW gitignore section header marker
|
|
34
75
|
*/
|
|
@@ -75,12 +116,11 @@ async function isSettingsFileEmpty(filePath) {
|
|
|
75
116
|
* Check if an IDE folder should be fully deleted.
|
|
76
117
|
* Returns true if:
|
|
77
118
|
* 1. The settings file is empty (or doesn't exist)
|
|
78
|
-
* 2. All
|
|
119
|
+
* 2. All subdirectories are empty (or don't exist)
|
|
79
120
|
* Backup files (e.g., settings.json.backup) are ignored.
|
|
80
121
|
*
|
|
81
122
|
* @param targetDir - Directory containing the IDE folder
|
|
82
123
|
* @param ideFolder - IDE folder configuration
|
|
83
|
-
* @param ideFolder.methodSubfolders - List of method subfolder names to check
|
|
84
124
|
* @param ideFolder.root - Root folder name (e.g., '.claude')
|
|
85
125
|
* @param ideFolder.settingsFile - Settings file name (e.g., 'settings.json')
|
|
86
126
|
* @returns True if the IDE folder should be fully deleted
|
|
@@ -104,25 +144,15 @@ async function shouldDeleteIdeFolder(targetDir, ideFolder) {
|
|
|
104
144
|
if (!settingsEmpty) {
|
|
105
145
|
return false;
|
|
106
146
|
}
|
|
107
|
-
// Check if all method subfolders are empty (in parallel)
|
|
108
|
-
const subfolderChecks = await Promise.all(ideFolder.methodSubfolders.map(async (subfolder) => {
|
|
109
|
-
const subfolderPath = join(ideFolderPath, subfolder);
|
|
110
|
-
return isDirectoryEmpty(subfolderPath);
|
|
111
|
-
}));
|
|
112
|
-
if (subfolderChecks.some((isEmpty) => !isEmpty)) {
|
|
113
|
-
return false;
|
|
114
|
-
}
|
|
115
147
|
// Check the IDE folder itself - ignore backup files and check for other meaningful content
|
|
116
148
|
try {
|
|
117
149
|
const entries = await fs.readdir(ideFolderPath);
|
|
118
|
-
// Filter entries to check (skip backup files
|
|
150
|
+
// Filter entries to check (skip backup files and settings file)
|
|
119
151
|
const entriesToCheck = entries.filter((entry) => {
|
|
120
152
|
if (entry.endsWith('.backup'))
|
|
121
153
|
return false;
|
|
122
154
|
if (entry === ideFolder.settingsFile)
|
|
123
155
|
return false;
|
|
124
|
-
if (ideFolder.methodSubfolders.includes(entry))
|
|
125
|
-
return false;
|
|
126
156
|
return true;
|
|
127
157
|
});
|
|
128
158
|
// Check all entries in parallel
|
|
@@ -321,8 +351,7 @@ async function updateIdeSettings(targetDir, ideFolder, methodsToRemove) {
|
|
|
321
351
|
if (hook.hooks && Array.isArray(hook.hooks)) {
|
|
322
352
|
const filteredInner = hook.hooks.filter((innerHook) => {
|
|
323
353
|
if (typeof innerHook.command === 'string') {
|
|
324
|
-
return !methodsToRemove.some((method) => innerHook.command?.toString().includes(`_${method}/`)
|
|
325
|
-
innerHook.command?.toString().includes(`/${method}-`));
|
|
354
|
+
return !methodsToRemove.some((method) => innerHook.command?.toString().includes(`_${method}/`));
|
|
326
355
|
}
|
|
327
356
|
return true;
|
|
328
357
|
});
|
|
@@ -382,6 +411,8 @@ export default class ClearCommand extends BaseCommand {
|
|
|
382
411
|
'<%= config.bin %> <%= command.id %> -t cc-native',
|
|
383
412
|
'<%= config.bin %> <%= command.id %> --dry-run',
|
|
384
413
|
'<%= config.bin %> <%= command.id %> --force',
|
|
414
|
+
'<%= config.bin %> <%= command.id %> --output',
|
|
415
|
+
'<%= config.bin %> <%= command.id %> --output --dry-run',
|
|
385
416
|
];
|
|
386
417
|
static flags = {
|
|
387
418
|
...BaseCommand.baseFlags,
|
|
@@ -395,14 +426,26 @@ export default class ClearCommand extends BaseCommand {
|
|
|
395
426
|
description: 'Skip confirmation prompt',
|
|
396
427
|
default: false,
|
|
397
428
|
}),
|
|
429
|
+
output: Flags.boolean({
|
|
430
|
+
char: 'o',
|
|
431
|
+
description: 'Clean runtime output artifacts (temp files, caches, log rotation, archives)',
|
|
432
|
+
default: false,
|
|
433
|
+
exclusive: ['template'],
|
|
434
|
+
}),
|
|
398
435
|
template: Flags.string({
|
|
399
436
|
char: 't',
|
|
400
437
|
description: 'Clear only a specific template (e.g., cc-native)',
|
|
438
|
+
exclusive: ['output'],
|
|
401
439
|
}),
|
|
402
440
|
};
|
|
403
441
|
async run() {
|
|
404
442
|
const { flags } = await this.parse(ClearCommand);
|
|
405
443
|
const targetDir = process.cwd();
|
|
444
|
+
// Handle --output flag separately (mutually exclusive with --template)
|
|
445
|
+
if (flags.output) {
|
|
446
|
+
await this.cleanRuntimeOutput(targetDir, flags);
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
406
449
|
try {
|
|
407
450
|
// Find all folders to clear
|
|
408
451
|
const workflowFolders = await this.findWorkflowFolders(targetDir, flags.template);
|
|
@@ -473,25 +516,36 @@ export default class ClearCommand extends BaseCommand {
|
|
|
473
516
|
catch {
|
|
474
517
|
return false;
|
|
475
518
|
}
|
|
476
|
-
//
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
519
|
+
// Scan all subdirectories to count method folders vs folders being deleted
|
|
520
|
+
let totalMethodFolders = 0;
|
|
521
|
+
let foldersBeingDeleted = 0;
|
|
522
|
+
try {
|
|
523
|
+
const topEntries = await fs.readdir(idePath, { withFileTypes: true });
|
|
524
|
+
const subdirs = topEntries.filter((e) => e.isDirectory());
|
|
525
|
+
// Check each subdirectory for method folders
|
|
526
|
+
const subResults = await Promise.all(subdirs.map(async (subdir) => {
|
|
527
|
+
const subdirPath = join(idePath, subdir.name);
|
|
528
|
+
try {
|
|
529
|
+
const entries = await fs.readdir(subdirPath, { withFileTypes: true });
|
|
530
|
+
const methodDirs = entries.filter((e) => e.isDirectory());
|
|
531
|
+
const deleted = methodDirs.filter((entry) => {
|
|
532
|
+
const fullPath = join(subdirPath, entry.name);
|
|
533
|
+
return ideMethodFolders.includes(fullPath);
|
|
534
|
+
}).length;
|
|
535
|
+
return { deleted, total: methodDirs.length };
|
|
536
|
+
}
|
|
537
|
+
catch {
|
|
538
|
+
return { deleted: 0, total: 0 };
|
|
539
|
+
}
|
|
540
|
+
}));
|
|
541
|
+
for (const r of subResults) {
|
|
542
|
+
totalMethodFolders += r.total;
|
|
543
|
+
foldersBeingDeleted += r.deleted;
|
|
491
544
|
}
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
|
|
545
|
+
}
|
|
546
|
+
catch {
|
|
547
|
+
return false;
|
|
548
|
+
}
|
|
495
549
|
// If all method folders are being deleted, check if settings would be empty
|
|
496
550
|
if (totalMethodFolders > 0 && totalMethodFolders === foldersBeingDeleted) {
|
|
497
551
|
// Check if settings file would become empty after removing methods
|
|
@@ -605,6 +659,11 @@ export default class ClearCommand extends BaseCommand {
|
|
|
605
659
|
await updateGitignoreAfterClear(targetDir, [AIWCLI_CONTAINER]);
|
|
606
660
|
this.logDebug('Updated .gitignore');
|
|
607
661
|
}
|
|
662
|
+
// Prune stale gitignore entries (paths that no longer exist on disk)
|
|
663
|
+
const pruned = await pruneGitignoreStaleEntries(targetDir);
|
|
664
|
+
if (pruned) {
|
|
665
|
+
this.logDebug('Pruned stale .gitignore entries');
|
|
666
|
+
}
|
|
608
667
|
// Update IDE settings files to remove method-specific entries
|
|
609
668
|
let updatedClaudeSettings = false;
|
|
610
669
|
let updatedWindsurfSettings = false;
|
|
@@ -674,7 +733,7 @@ export default class ClearCommand extends BaseCommand {
|
|
|
674
733
|
parts.push(`${IDE_FOLDERS.windsurf.root}/ folder`);
|
|
675
734
|
}
|
|
676
735
|
this.logSuccess(`Cleared: ${parts.join(', ')}.`);
|
|
677
|
-
if (removedAiwcliContainer) {
|
|
736
|
+
if (removedAiwcliContainer || pruned) {
|
|
678
737
|
this.logSuccess('Updated .gitignore.');
|
|
679
738
|
}
|
|
680
739
|
if (updatedClaudeSettings) {
|
|
@@ -713,40 +772,49 @@ export default class ClearCommand extends BaseCommand {
|
|
|
713
772
|
return methods;
|
|
714
773
|
}
|
|
715
774
|
/**
|
|
716
|
-
* Find all IDE method folders
|
|
717
|
-
*
|
|
775
|
+
* Find all IDE method folders by scanning subdirectories of each IDE root
|
|
776
|
+
* for children matching installed method names.
|
|
777
|
+
*
|
|
778
|
+
* For example, finds .claude/commands/cc-native/, .claude/skills/cc-native/,
|
|
779
|
+
* .windsurf/workflows/cc-native/ — without hardcoding which subdirectories exist.
|
|
718
780
|
*
|
|
719
781
|
* @param targetDir - Directory to search in
|
|
720
782
|
* @param template - Optional template/method name to filter by
|
|
721
783
|
* @returns Array of IDE method folder paths
|
|
722
784
|
*/
|
|
723
785
|
async findIdeMethodFolders(targetDir, template) {
|
|
724
|
-
// Build
|
|
725
|
-
const
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
for (const subfolder of ide.methodSubfolders) {
|
|
729
|
-
subfolderChecks.push({ ideRoot, subfolder });
|
|
730
|
-
}
|
|
786
|
+
// Build method set: from --template flag, or from installed methods
|
|
787
|
+
const methodNames = template ? new Set([template]) : await getInstalledMethodNames(targetDir);
|
|
788
|
+
if (methodNames.size === 0) {
|
|
789
|
+
return [];
|
|
731
790
|
}
|
|
732
|
-
//
|
|
733
|
-
const
|
|
734
|
-
|
|
791
|
+
// For each IDE root, scan all subdirectories for children matching method names
|
|
792
|
+
const ideRoots = Object.values(IDE_FOLDERS).map((ide) => join(targetDir, ide.root));
|
|
793
|
+
const ideResults = await Promise.all(ideRoots.map(async (ideRoot) => {
|
|
794
|
+
// Get all subdirectories of IDE root (e.g., .claude/commands/, .claude/skills/)
|
|
735
795
|
try {
|
|
736
|
-
const
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
const
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
796
|
+
const topEntries = await fs.readdir(ideRoot, { withFileTypes: true });
|
|
797
|
+
const subdirs = topEntries.filter((e) => e.isDirectory());
|
|
798
|
+
// For each subdirectory, check for method-named children
|
|
799
|
+
const subResults = await Promise.all(subdirs.map(async (subdir) => {
|
|
800
|
+
const subdirPath = join(ideRoot, subdir.name);
|
|
801
|
+
try {
|
|
802
|
+
const entries = await fs.readdir(subdirPath, { withFileTypes: true });
|
|
803
|
+
return entries
|
|
804
|
+
.filter((entry) => entry.isDirectory() && methodNames.has(entry.name))
|
|
805
|
+
.map((entry) => join(subdirPath, entry.name));
|
|
806
|
+
}
|
|
807
|
+
catch {
|
|
808
|
+
return [];
|
|
809
|
+
}
|
|
810
|
+
}));
|
|
811
|
+
return subResults.flat();
|
|
744
812
|
}
|
|
745
813
|
catch {
|
|
746
814
|
return [];
|
|
747
815
|
}
|
|
748
816
|
}));
|
|
749
|
-
return
|
|
817
|
+
return ideResults.flat();
|
|
750
818
|
}
|
|
751
819
|
/**
|
|
752
820
|
* Find all output folders in the target directory.
|
|
@@ -832,4 +900,176 @@ export default class ClearCommand extends BaseCommand {
|
|
|
832
900
|
}
|
|
833
901
|
return foundFolders;
|
|
834
902
|
}
|
|
903
|
+
/**
|
|
904
|
+
* Clean runtime output artifacts from _output/ at project root.
|
|
905
|
+
* Handles temp files, cache files, log rotation, and archive cleanup.
|
|
906
|
+
*
|
|
907
|
+
* @param targetDir - Project root directory
|
|
908
|
+
* @param flags - Command flags (dry-run, force)
|
|
909
|
+
*/
|
|
910
|
+
async cleanRuntimeOutput(targetDir, flags) {
|
|
911
|
+
const outputDir = join(targetDir, '_output');
|
|
912
|
+
if (!(await pathExists(outputDir))) {
|
|
913
|
+
this.logInfo('No _output/ directory found.');
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
916
|
+
const toDelete = [];
|
|
917
|
+
let logAction = null;
|
|
918
|
+
let archiveDir = null;
|
|
919
|
+
let archiveCount = 0;
|
|
920
|
+
try {
|
|
921
|
+
const entries = await fs.readdir(outputDir, { withFileTypes: true });
|
|
922
|
+
for (const entry of entries) {
|
|
923
|
+
const entryPath = join(outputDir, entry.name);
|
|
924
|
+
// Temp files: .index_*.tmp (orphaned atomic write files)
|
|
925
|
+
if (entry.isFile() && entry.name.startsWith('.index_') && entry.name.endsWith('.tmp')) {
|
|
926
|
+
toDelete.push({ path: entryPath, reason: 'temp file' });
|
|
927
|
+
continue;
|
|
928
|
+
}
|
|
929
|
+
// Cache files: .*-cache.json
|
|
930
|
+
if (entry.isFile() && entry.name.startsWith('.') && entry.name.endsWith('-cache.json')) {
|
|
931
|
+
toDelete.push({ path: entryPath, reason: 'cache file' });
|
|
932
|
+
continue;
|
|
933
|
+
}
|
|
934
|
+
// Log rotation: hook-log.jsonl > 1MB
|
|
935
|
+
if (entry.isFile() && entry.name === 'hook-log.jsonl') {
|
|
936
|
+
try {
|
|
937
|
+
const stat = await fs.stat(entryPath);
|
|
938
|
+
if (stat.size > 1_048_576) {
|
|
939
|
+
logAction = { path: entryPath, sizeBytes: stat.size };
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
catch {
|
|
943
|
+
// Can't stat log file
|
|
944
|
+
}
|
|
945
|
+
continue;
|
|
946
|
+
}
|
|
947
|
+
// Archive cleanup: contexts/_archive/
|
|
948
|
+
if (entry.isDirectory() && entry.name === 'contexts') {
|
|
949
|
+
const archivePath = join(entryPath, '_archive');
|
|
950
|
+
try {
|
|
951
|
+
const archiveEntries = await fs.readdir(archivePath);
|
|
952
|
+
if (archiveEntries.length > 0) {
|
|
953
|
+
archiveDir = archivePath;
|
|
954
|
+
archiveCount = archiveEntries.length;
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
catch {
|
|
958
|
+
// No archive directory
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
catch (error) {
|
|
964
|
+
const err = error;
|
|
965
|
+
this.error(`Cannot read _output/: ${err.message}`, {
|
|
966
|
+
exit: EXIT_CODES.GENERAL_ERROR,
|
|
967
|
+
});
|
|
968
|
+
}
|
|
969
|
+
// Nothing to clean
|
|
970
|
+
if (toDelete.length === 0 && !logAction && !archiveDir) {
|
|
971
|
+
this.logInfo('No runtime output artifacts to clean.');
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
// Show what will be cleaned
|
|
975
|
+
this.log('');
|
|
976
|
+
this.logInfo('Runtime output cleanup:');
|
|
977
|
+
if (toDelete.length > 0) {
|
|
978
|
+
for (const item of toDelete) {
|
|
979
|
+
const relativePath = item.path.replace(targetDir + '\\', '').replace(targetDir + '/', '');
|
|
980
|
+
this.log(` ${relativePath} (${item.reason})`);
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
if (logAction) {
|
|
984
|
+
const sizeMB = (logAction.sizeBytes / 1_048_576).toFixed(1);
|
|
985
|
+
this.log(` _output/hook-log.jsonl (${sizeMB}MB → truncate to ~512KB)`);
|
|
986
|
+
}
|
|
987
|
+
if (archiveDir) {
|
|
988
|
+
this.log(` _output/contexts/_archive/ (${archiveCount} archived context(s))`);
|
|
989
|
+
}
|
|
990
|
+
this.log('');
|
|
991
|
+
// Dry run
|
|
992
|
+
if (flags['dry-run']) {
|
|
993
|
+
this.logInfo('Dry run complete. No files were modified.');
|
|
994
|
+
return;
|
|
995
|
+
}
|
|
996
|
+
// Confirm archive deletion (unless --force)
|
|
997
|
+
if (archiveDir && !flags.force) {
|
|
998
|
+
const shouldDelete = await confirm({
|
|
999
|
+
message: `Delete ${archiveCount} archived context(s)?`,
|
|
1000
|
+
default: false,
|
|
1001
|
+
});
|
|
1002
|
+
if (!shouldDelete) {
|
|
1003
|
+
archiveDir = null;
|
|
1004
|
+
archiveCount = 0;
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
// Execute deletions
|
|
1008
|
+
let deletedCount = 0;
|
|
1009
|
+
for (const item of toDelete) {
|
|
1010
|
+
try {
|
|
1011
|
+
await fs.unlink(item.path);
|
|
1012
|
+
deletedCount++;
|
|
1013
|
+
}
|
|
1014
|
+
catch (error) {
|
|
1015
|
+
const err = error;
|
|
1016
|
+
this.logWarning(`Failed to delete ${item.path}: ${err.message}`);
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
// Log rotation
|
|
1020
|
+
if (logAction) {
|
|
1021
|
+
try {
|
|
1022
|
+
const content = await fs.readFile(logAction.path, 'utf8');
|
|
1023
|
+
// Keep the most recent 512KB
|
|
1024
|
+
const truncated = content.slice(-524_288);
|
|
1025
|
+
// Find the first complete line
|
|
1026
|
+
const firstNewline = truncated.indexOf('\n');
|
|
1027
|
+
const cleaned = firstNewline >= 0 ? truncated.slice(firstNewline + 1) : truncated;
|
|
1028
|
+
await fs.writeFile(logAction.path, cleaned, 'utf8');
|
|
1029
|
+
this.logDebug('Rotated hook-log.jsonl');
|
|
1030
|
+
}
|
|
1031
|
+
catch (error) {
|
|
1032
|
+
const err = error;
|
|
1033
|
+
this.logWarning(`Failed to rotate log: ${err.message}`);
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
// Archive cleanup
|
|
1037
|
+
let archivedCleaned = 0;
|
|
1038
|
+
if (archiveDir) {
|
|
1039
|
+
try {
|
|
1040
|
+
const archiveEntries = await fs.readdir(archiveDir);
|
|
1041
|
+
await Promise.all(archiveEntries.map(async (entry) => {
|
|
1042
|
+
try {
|
|
1043
|
+
await fs.rm(join(archiveDir, entry), { force: true, recursive: true });
|
|
1044
|
+
archivedCleaned++;
|
|
1045
|
+
}
|
|
1046
|
+
catch {
|
|
1047
|
+
// Individual entry failed
|
|
1048
|
+
}
|
|
1049
|
+
}));
|
|
1050
|
+
}
|
|
1051
|
+
catch (error) {
|
|
1052
|
+
const err = error;
|
|
1053
|
+
this.logWarning(`Failed to clean archive: ${err.message}`);
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
// Summary
|
|
1057
|
+
this.log('');
|
|
1058
|
+
const parts = [];
|
|
1059
|
+
if (deletedCount > 0) {
|
|
1060
|
+
parts.push(`${deletedCount} file(s) removed`);
|
|
1061
|
+
}
|
|
1062
|
+
if (logAction) {
|
|
1063
|
+
parts.push('log rotated');
|
|
1064
|
+
}
|
|
1065
|
+
if (archivedCleaned > 0) {
|
|
1066
|
+
parts.push(`${archivedCleaned} archived context(s) removed`);
|
|
1067
|
+
}
|
|
1068
|
+
if (parts.length > 0) {
|
|
1069
|
+
this.logSuccess(`Output cleanup: ${parts.join(', ')}.`);
|
|
1070
|
+
}
|
|
1071
|
+
else {
|
|
1072
|
+
this.logInfo('No changes made.');
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
835
1075
|
}
|
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prune stale entries from the AIW Installation section in .gitignore.
|
|
3
|
+
* Checks each entry against disk existence and removes entries whose paths don't exist.
|
|
4
|
+
* Removes the entire section if no entries remain after pruning.
|
|
5
|
+
*
|
|
6
|
+
* @param targetDir - Directory containing .gitignore
|
|
7
|
+
* @returns True if any entries were pruned
|
|
8
|
+
*/
|
|
9
|
+
export declare function pruneGitignoreStaleEntries(targetDir: string): Promise<boolean>;
|
|
1
10
|
/**
|
|
2
11
|
* Update .gitignore with patterns for installed folders.
|
|
3
12
|
*
|
|
@@ -1,5 +1,126 @@
|
|
|
1
1
|
import { promises as fs } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
+
import { pathExists } from './paths.js';
|
|
4
|
+
/**
|
|
5
|
+
* AIW gitignore section header marker
|
|
6
|
+
*/
|
|
7
|
+
const AIW_GITIGNORE_HEADER = '# AIW Installation';
|
|
8
|
+
/**
|
|
9
|
+
* Prune stale entries from the AIW Installation section in .gitignore.
|
|
10
|
+
* Checks each entry against disk existence and removes entries whose paths don't exist.
|
|
11
|
+
* Removes the entire section if no entries remain after pruning.
|
|
12
|
+
*
|
|
13
|
+
* @param targetDir - Directory containing .gitignore
|
|
14
|
+
* @returns True if any entries were pruned
|
|
15
|
+
*/
|
|
16
|
+
export async function pruneGitignoreStaleEntries(targetDir) {
|
|
17
|
+
const gitignorePath = join(targetDir, '.gitignore');
|
|
18
|
+
try {
|
|
19
|
+
const content = await fs.readFile(gitignorePath, 'utf8');
|
|
20
|
+
if (!content.includes(AIW_GITIGNORE_HEADER)) {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
const lines = content.split('\n');
|
|
24
|
+
const newLines = [];
|
|
25
|
+
let inAiwSection = false;
|
|
26
|
+
const aiwSectionLines = [];
|
|
27
|
+
let pruned = false;
|
|
28
|
+
for (const line of lines) {
|
|
29
|
+
if (line === AIW_GITIGNORE_HEADER) {
|
|
30
|
+
inAiwSection = true;
|
|
31
|
+
aiwSectionLines.push(line);
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (inAiwSection) {
|
|
35
|
+
// AIW section ends at empty line or another comment header
|
|
36
|
+
if (line === '' || (line.startsWith('#') && line !== AIW_GITIGNORE_HEADER)) {
|
|
37
|
+
inAiwSection = false;
|
|
38
|
+
const { lines: filtered, pruned: sectionPruned } = await pruneSection(aiwSectionLines, targetDir);
|
|
39
|
+
if (sectionPruned)
|
|
40
|
+
pruned = true;
|
|
41
|
+
newLines.push(...filtered, line);
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
aiwSectionLines.push(line);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
newLines.push(line);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// Handle case where AIW section is at end of file
|
|
52
|
+
if (inAiwSection) {
|
|
53
|
+
const { lines: filtered, pruned: sectionPruned } = await pruneSection(aiwSectionLines, targetDir);
|
|
54
|
+
if (sectionPruned)
|
|
55
|
+
pruned = true;
|
|
56
|
+
newLines.push(...filtered);
|
|
57
|
+
}
|
|
58
|
+
if (!pruned) {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
// Clean up: remove AIW section entirely if only header remains
|
|
62
|
+
let result = cleanupEmptySections(newLines.join('\n'));
|
|
63
|
+
// Ensure file ends properly
|
|
64
|
+
result = result.replace(/\n+$/, '\n');
|
|
65
|
+
if (result.trim() === '') {
|
|
66
|
+
result = '';
|
|
67
|
+
}
|
|
68
|
+
await fs.writeFile(gitignorePath, result, 'utf8');
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Prune stale entries from a parsed AIW section.
|
|
77
|
+
* Checks each gitignore pattern against disk existence.
|
|
78
|
+
*/
|
|
79
|
+
async function pruneSection(sectionLines, targetDir) {
|
|
80
|
+
let pruned = false;
|
|
81
|
+
const filtered = [];
|
|
82
|
+
for (const line of sectionLines) {
|
|
83
|
+
// Always keep the header
|
|
84
|
+
if (line === AIW_GITIGNORE_HEADER) {
|
|
85
|
+
filtered.push(line);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
// Check if the path exists on disk
|
|
89
|
+
const cleanPath = line.replace(/^\//, '').replace(/\/$/, '');
|
|
90
|
+
const absPath = join(targetDir, cleanPath);
|
|
91
|
+
if (await pathExists(absPath)) {
|
|
92
|
+
filtered.push(line);
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
pruned = true;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return { lines: filtered, pruned };
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Remove empty AIW sections (header with no patterns following).
|
|
102
|
+
*/
|
|
103
|
+
function cleanupEmptySections(content) {
|
|
104
|
+
const lines = content.split('\n');
|
|
105
|
+
const newLines = [];
|
|
106
|
+
for (let i = 0; i < lines.length; i++) {
|
|
107
|
+
const line = lines[i];
|
|
108
|
+
if (line === AIW_GITIGNORE_HEADER) {
|
|
109
|
+
// Look ahead to see if there are any patterns
|
|
110
|
+
const nextLine = lines[i + 1];
|
|
111
|
+
if (nextLine === undefined || nextLine === '' || nextLine.startsWith('#')) {
|
|
112
|
+
// Skip the header — section is empty
|
|
113
|
+
// Also remove trailing empty lines before the header
|
|
114
|
+
while (newLines.length > 0 && newLines.at(-1) === '') {
|
|
115
|
+
newLines.pop();
|
|
116
|
+
}
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
newLines.push(line);
|
|
121
|
+
}
|
|
122
|
+
return newLines.join('\n');
|
|
123
|
+
}
|
|
3
124
|
/**
|
|
4
125
|
* Update .gitignore with patterns for installed folders.
|
|
5
126
|
*
|
|
Binary file
|
|
Binary file
|