create-sdd-project 0.16.8 → 0.16.9
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/lib/doctor.js +327 -0
- package/package.json +1 -1
package/lib/doctor.js
CHANGED
|
@@ -73,6 +73,9 @@ function runDoctor(cwd) {
|
|
|
73
73
|
// 12. Gemini Settings Format
|
|
74
74
|
results.push(checkGeminiSettings(cwd, aiTools));
|
|
75
75
|
|
|
76
|
+
// 13. Gemini TOML Commands Format
|
|
77
|
+
results.push(checkGeminiCommands(cwd, aiTools));
|
|
78
|
+
|
|
76
79
|
return results;
|
|
77
80
|
}
|
|
78
81
|
|
|
@@ -613,6 +616,330 @@ function checkGeminiSettings(cwd, aiTools) {
|
|
|
613
616
|
};
|
|
614
617
|
}
|
|
615
618
|
|
|
619
|
+
/**
|
|
620
|
+
* Validate a .gemini/commands/*.toml file using a strict subset of TOML
|
|
621
|
+
* grammar sufficient for our narrow use case.
|
|
622
|
+
*
|
|
623
|
+
* Scope: the templates we ship only use two top-level keys (`description`,
|
|
624
|
+
* `prompt`) with string values — standard quoted (`"..."`), single-quoted
|
|
625
|
+
* literal (`'...'`), or triple-quoted multiline (`"""..."""` / `'''...'''`).
|
|
626
|
+
* This validator enforces that subset strictly:
|
|
627
|
+
*
|
|
628
|
+
* - Each non-blank, non-comment line must be either a top-level assignment
|
|
629
|
+
* `key = <string-literal>` or the start of a multiline string
|
|
630
|
+
* - Top-level keys must match `[A-Za-z][A-Za-z0-9_-]*` (bare keys only —
|
|
631
|
+
* quoted keys like `"prompt" = "x"` are flagged as invalid; our templates
|
|
632
|
+
* never use them)
|
|
633
|
+
* - Duplicate top-level keys are rejected (TOML spec forbids them)
|
|
634
|
+
* - Strings must be properly closed on the same line (except triple-quoted,
|
|
635
|
+
* which can span lines)
|
|
636
|
+
* - Trailing content after a closed string is rejected (only a `#` comment
|
|
637
|
+
* is allowed after the value)
|
|
638
|
+
* - Values that are not string literals (numbers, booleans, arrays, etc.)
|
|
639
|
+
* are flagged as non-string
|
|
640
|
+
* - Assignments inside `[table]` or `[[array-table]]` sections are not
|
|
641
|
+
* considered top-level and the scan stops there (our templates don't use
|
|
642
|
+
* tables)
|
|
643
|
+
*
|
|
644
|
+
* This validator is intentionally stricter than full TOML and looser in a
|
|
645
|
+
* few edge cases (e.g., escape sequences inside basic strings are accepted
|
|
646
|
+
* as `\\.`). The goal is to catch files that Gemini CLI's FileCommandLoader
|
|
647
|
+
* would silently skip — not to be a general-purpose TOML parser. If our
|
|
648
|
+
* templates ever need richer TOML features, upgrade to `@iarna/toml` as
|
|
649
|
+
* a runtime dependency at that point.
|
|
650
|
+
*
|
|
651
|
+
* Returns:
|
|
652
|
+
* { ok: true, keys: { prompt?: 'string' | 'non-string', description?: 'string' | 'non-string' } }
|
|
653
|
+
* { ok: false, error: '<message>', line: N }
|
|
654
|
+
*/
|
|
655
|
+
function validateTomlCommandFile(content) {
|
|
656
|
+
const keysSeen = {};
|
|
657
|
+
const lines = content.split(/\r?\n|\r/);
|
|
658
|
+
let i = 0;
|
|
659
|
+
|
|
660
|
+
while (i < lines.length) {
|
|
661
|
+
const raw = lines[i];
|
|
662
|
+
const trimmed = raw.trim();
|
|
663
|
+
|
|
664
|
+
// Blank line or full-line comment
|
|
665
|
+
if (trimmed === '' || trimmed.startsWith('#')) {
|
|
666
|
+
i++;
|
|
667
|
+
continue;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// Table / array-table — end of top-level scope, stop scanning
|
|
671
|
+
if (/^\[\[?/.test(trimmed)) {
|
|
672
|
+
break;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// Top-level assignment: bare key = value
|
|
676
|
+
const keyMatch = trimmed.match(/^([A-Za-z][A-Za-z0-9_-]*)\s*=\s*(.*)$/);
|
|
677
|
+
if (!keyMatch) {
|
|
678
|
+
return {
|
|
679
|
+
ok: false,
|
|
680
|
+
error: `line ${i + 1}: not a valid top-level assignment: ${trimmed.slice(0, 60)}`,
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
const key = keyMatch[1];
|
|
685
|
+
const value = keyMatch[2];
|
|
686
|
+
|
|
687
|
+
if (keysSeen[key] !== undefined) {
|
|
688
|
+
return { ok: false, error: `line ${i + 1}: duplicate top-level key '${key}'` };
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// Multi-line basic string: """..."""
|
|
692
|
+
if (value.startsWith('"""')) {
|
|
693
|
+
const after = value.slice(3);
|
|
694
|
+
const closeIdx = after.indexOf('"""');
|
|
695
|
+
if (closeIdx !== -1) {
|
|
696
|
+
// Closed on same line — check no trailing content except optional comment
|
|
697
|
+
const trailing = after.slice(closeIdx + 3).trim();
|
|
698
|
+
if (trailing !== '' && !trailing.startsWith('#')) {
|
|
699
|
+
return {
|
|
700
|
+
ok: false,
|
|
701
|
+
error: `line ${i + 1}: trailing content after """ close: ${trailing.slice(0, 40)}`,
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
keysSeen[key] = 'string';
|
|
705
|
+
i++;
|
|
706
|
+
continue;
|
|
707
|
+
}
|
|
708
|
+
// Scan forward for closing """
|
|
709
|
+
let j = i + 1;
|
|
710
|
+
let closed = false;
|
|
711
|
+
while (j < lines.length) {
|
|
712
|
+
const idx2 = lines[j].indexOf('"""');
|
|
713
|
+
if (idx2 !== -1) {
|
|
714
|
+
const trailing2 = lines[j].slice(idx2 + 3).trim();
|
|
715
|
+
if (trailing2 !== '' && !trailing2.startsWith('#')) {
|
|
716
|
+
return {
|
|
717
|
+
ok: false,
|
|
718
|
+
error: `line ${j + 1}: trailing content after """ close: ${trailing2.slice(0, 40)}`,
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
closed = true;
|
|
722
|
+
i = j + 1;
|
|
723
|
+
break;
|
|
724
|
+
}
|
|
725
|
+
j++;
|
|
726
|
+
}
|
|
727
|
+
if (!closed) {
|
|
728
|
+
return {
|
|
729
|
+
ok: false,
|
|
730
|
+
error: `line ${i + 1}: unterminated triple-quoted basic string (""" never closed)`,
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
keysSeen[key] = 'string';
|
|
734
|
+
continue;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// Multi-line literal string: '''...'''
|
|
738
|
+
if (value.startsWith("'''")) {
|
|
739
|
+
const after = value.slice(3);
|
|
740
|
+
const closeIdx = after.indexOf("'''");
|
|
741
|
+
if (closeIdx !== -1) {
|
|
742
|
+
const trailing = after.slice(closeIdx + 3).trim();
|
|
743
|
+
if (trailing !== '' && !trailing.startsWith('#')) {
|
|
744
|
+
return {
|
|
745
|
+
ok: false,
|
|
746
|
+
error: `line ${i + 1}: trailing content after ''' close: ${trailing.slice(0, 40)}`,
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
keysSeen[key] = 'string';
|
|
750
|
+
i++;
|
|
751
|
+
continue;
|
|
752
|
+
}
|
|
753
|
+
let j = i + 1;
|
|
754
|
+
let closed = false;
|
|
755
|
+
while (j < lines.length) {
|
|
756
|
+
const idx2 = lines[j].indexOf("'''");
|
|
757
|
+
if (idx2 !== -1) {
|
|
758
|
+
const trailing2 = lines[j].slice(idx2 + 3).trim();
|
|
759
|
+
if (trailing2 !== '' && !trailing2.startsWith('#')) {
|
|
760
|
+
return {
|
|
761
|
+
ok: false,
|
|
762
|
+
error: `line ${j + 1}: trailing content after ''' close: ${trailing2.slice(0, 40)}`,
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
closed = true;
|
|
766
|
+
i = j + 1;
|
|
767
|
+
break;
|
|
768
|
+
}
|
|
769
|
+
j++;
|
|
770
|
+
}
|
|
771
|
+
if (!closed) {
|
|
772
|
+
return {
|
|
773
|
+
ok: false,
|
|
774
|
+
error: `line ${i + 1}: unterminated triple-quoted literal string (''' never closed)`,
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
keysSeen[key] = 'string';
|
|
778
|
+
continue;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// Basic string: "..." with standard escapes; must close on same line
|
|
782
|
+
// and allow only a trailing comment after the closing quote.
|
|
783
|
+
if (value.startsWith('"')) {
|
|
784
|
+
const basicMatch = value.match(/^"((?:[^"\\]|\\.)*)"(?:\s*(?:#.*)?)?$/);
|
|
785
|
+
if (!basicMatch) {
|
|
786
|
+
return {
|
|
787
|
+
ok: false,
|
|
788
|
+
error: `line ${i + 1}: invalid basic string value (unterminated or trailing content): ${value.slice(0, 60)}`,
|
|
789
|
+
};
|
|
790
|
+
}
|
|
791
|
+
keysSeen[key] = 'string';
|
|
792
|
+
i++;
|
|
793
|
+
continue;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// Literal string: '...' with no escapes; must close on same line
|
|
797
|
+
if (value.startsWith("'")) {
|
|
798
|
+
const litMatch = value.match(/^'([^']*)'(?:\s*(?:#.*)?)?$/);
|
|
799
|
+
if (!litMatch) {
|
|
800
|
+
return {
|
|
801
|
+
ok: false,
|
|
802
|
+
error: `line ${i + 1}: invalid literal string value (unterminated or trailing content): ${value.slice(0, 60)}`,
|
|
803
|
+
};
|
|
804
|
+
}
|
|
805
|
+
keysSeen[key] = 'string';
|
|
806
|
+
i++;
|
|
807
|
+
continue;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// Any other value is not a string literal (int, bool, array, table, etc.)
|
|
811
|
+
keysSeen[key] = 'non-string';
|
|
812
|
+
i++;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
return { ok: true, keys: keysSeen };
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
function checkGeminiCommands(cwd, aiTools) {
|
|
819
|
+
if (aiTools === 'claude') {
|
|
820
|
+
return {
|
|
821
|
+
status: PASS,
|
|
822
|
+
message: 'Gemini commands: N/A (Claude only)',
|
|
823
|
+
details: [],
|
|
824
|
+
};
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
const commandsDir = path.join(cwd, '.gemini', 'commands');
|
|
828
|
+
if (!fs.existsSync(commandsDir)) {
|
|
829
|
+
return {
|
|
830
|
+
status: WARN,
|
|
831
|
+
message: 'Gemini commands: .gemini/commands/ missing',
|
|
832
|
+
details: ['Run: npx create-sdd-project --upgrade to recreate template commands'],
|
|
833
|
+
};
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
// readdirSync with withFileTypes so we can filter symlinks before reading.
|
|
837
|
+
// Symlinks in .gemini/commands/ would make doctor read arbitrary files on
|
|
838
|
+
// the user's machine — low severity in a local CLI, but worth guarding.
|
|
839
|
+
const entries = fs
|
|
840
|
+
.readdirSync(commandsDir, { withFileTypes: true })
|
|
841
|
+
.filter((e) => e.name.endsWith('.toml'))
|
|
842
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
843
|
+
|
|
844
|
+
if (entries.length === 0) {
|
|
845
|
+
return {
|
|
846
|
+
status: WARN,
|
|
847
|
+
message: 'Gemini commands: no .toml files in .gemini/commands/',
|
|
848
|
+
details: ['Gemini CLI slash commands require .toml files. Run: npx create-sdd-project --upgrade'],
|
|
849
|
+
};
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
const issues = [];
|
|
853
|
+
let validCount = 0;
|
|
854
|
+
|
|
855
|
+
for (const entry of entries) {
|
|
856
|
+
const file = entry.name;
|
|
857
|
+
const filePath = path.join(commandsDir, file);
|
|
858
|
+
|
|
859
|
+
// Reject symlinks (Dirent can lie about isFile() when followed; use lstat).
|
|
860
|
+
let lst;
|
|
861
|
+
try {
|
|
862
|
+
lst = fs.lstatSync(filePath);
|
|
863
|
+
} catch (e) {
|
|
864
|
+
issues.push(`${file}: cannot lstat (${e.code || e.message})`);
|
|
865
|
+
continue;
|
|
866
|
+
}
|
|
867
|
+
if (lst.isSymbolicLink()) {
|
|
868
|
+
issues.push(`${file}: is a symlink — refusing to follow (security). Delete and run --upgrade to restore template`);
|
|
869
|
+
continue;
|
|
870
|
+
}
|
|
871
|
+
if (!lst.isFile()) {
|
|
872
|
+
issues.push(`${file}: not a regular file`);
|
|
873
|
+
continue;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
let content;
|
|
877
|
+
try {
|
|
878
|
+
content = fs.readFileSync(filePath, 'utf8');
|
|
879
|
+
} catch (e) {
|
|
880
|
+
issues.push(`${file}: cannot read (${e.code || e.message})`);
|
|
881
|
+
continue;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
if (content.trim() === '') {
|
|
885
|
+
issues.push(`${file}: empty file (Gemini CLI will skip this command silently)`);
|
|
886
|
+
continue;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
// Validate using the strict grammar subset for our templates.
|
|
890
|
+
// Gemini CLI's FileCommandLoader schema is:
|
|
891
|
+
// z.object({ prompt: z.string(), description: z.string().optional() })
|
|
892
|
+
const result = validateTomlCommandFile(content);
|
|
893
|
+
|
|
894
|
+
if (!result.ok) {
|
|
895
|
+
issues.push(`${file}: ${result.error}`);
|
|
896
|
+
continue;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
const promptKind = result.keys.prompt;
|
|
900
|
+
const descriptionKind = result.keys.description;
|
|
901
|
+
|
|
902
|
+
if (promptKind === undefined) {
|
|
903
|
+
issues.push(
|
|
904
|
+
`${file}: missing required field 'prompt' (Gemini CLI will silently skip this command)`
|
|
905
|
+
);
|
|
906
|
+
continue;
|
|
907
|
+
}
|
|
908
|
+
if (promptKind !== 'string') {
|
|
909
|
+
issues.push(
|
|
910
|
+
`${file}: 'prompt' field must be a string (Gemini CLI requires z.string())`
|
|
911
|
+
);
|
|
912
|
+
continue;
|
|
913
|
+
}
|
|
914
|
+
if (descriptionKind !== undefined && descriptionKind !== 'string') {
|
|
915
|
+
issues.push(
|
|
916
|
+
`${file}: 'description' field is present but is not a string`
|
|
917
|
+
);
|
|
918
|
+
continue;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
validCount++;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
if (issues.length > 0) {
|
|
925
|
+
return {
|
|
926
|
+
status: FAIL,
|
|
927
|
+
message: `Gemini commands: ${issues.length} invalid TOML file${issues.length > 1 ? 's' : ''}`,
|
|
928
|
+
details: [
|
|
929
|
+
...issues,
|
|
930
|
+
'Gemini CLI silently skips invalid TOML commands — they will not appear as slash commands in the UI.',
|
|
931
|
+
'Run: npx create-sdd-project --upgrade to restore template commands.',
|
|
932
|
+
],
|
|
933
|
+
};
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
return {
|
|
937
|
+
status: PASS,
|
|
938
|
+
message: `Gemini commands: ${validCount}/${entries.length} valid`,
|
|
939
|
+
details: [],
|
|
940
|
+
};
|
|
941
|
+
}
|
|
942
|
+
|
|
616
943
|
module.exports = {
|
|
617
944
|
runDoctor,
|
|
618
945
|
printResults,
|