skvlt 0.9.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/LICENSE +21 -0
- package/README.md +199 -0
- package/README.zh.md +199 -0
- package/package.json +55 -0
- package/src/cli.ts +211 -0
- package/src/commands/backup.ts +224 -0
- package/src/commands/completion.ts +197 -0
- package/src/commands/doctor.ts +257 -0
- package/src/commands/restore.ts +689 -0
- package/src/internal/args/parse-backup-args.ts +77 -0
- package/src/internal/args/parse-completion-args.ts +30 -0
- package/src/internal/args/parse-doctor-args.ts +40 -0
- package/src/internal/args/parse-restore-args.ts +119 -0
- package/src/internal/cli/theme.ts +136 -0
- package/src/internal/install/global-skill-state.ts +102 -0
- package/src/internal/install/resolve-install-concurrency.ts +74 -0
- package/src/internal/install/run-with-concurrency.ts +72 -0
- package/src/internal/install/skills-add-command.ts +53 -0
- package/src/internal/manifest/build-manifest.ts +78 -0
- package/src/internal/manifest/manifest-types.ts +27 -0
- package/src/internal/manifest/parse-manifest.ts +200 -0
- package/src/internal/paths/defaults.ts +19 -0
- package/src/internal/paths/format-cli-path.ts +13 -0
- package/src/internal/process/list-installed-skill-names.ts +27 -0
- package/src/internal/process/run-bunx.ts +27 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
defaultManifestOptionPath,
|
|
5
|
+
defaultGlobalLockFilePath,
|
|
6
|
+
} from "../internal/paths/defaults";
|
|
7
|
+
import {
|
|
8
|
+
commandErrorPage,
|
|
9
|
+
helpExample,
|
|
10
|
+
helpFooter,
|
|
11
|
+
helpHeading,
|
|
12
|
+
infoLine,
|
|
13
|
+
summaryLine,
|
|
14
|
+
errorMessage,
|
|
15
|
+
page,
|
|
16
|
+
} from "../internal/cli/theme";
|
|
17
|
+
import { formatCliPath } from "../internal/paths/format-cli-path";
|
|
18
|
+
import { parseBackupArgs } from "../internal/args/parse-backup-args";
|
|
19
|
+
import { buildManifest } from "../internal/manifest/build-manifest";
|
|
20
|
+
import type { LockFile } from "../internal/manifest/manifest-types";
|
|
21
|
+
import { listInstalledSkillNames } from "../internal/process/list-installed-skill-names";
|
|
22
|
+
|
|
23
|
+
export type BackupRunResult = {
|
|
24
|
+
exitCode: number;
|
|
25
|
+
stdout: string;
|
|
26
|
+
stderr: string;
|
|
27
|
+
errorCode?: string;
|
|
28
|
+
payload?: {
|
|
29
|
+
dryRun: boolean;
|
|
30
|
+
outputPath: string;
|
|
31
|
+
projectScope: boolean;
|
|
32
|
+
totalSkills: number;
|
|
33
|
+
totalSources: number;
|
|
34
|
+
manifest?: string;
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type BackupDependencies = {
|
|
39
|
+
getInstalledSkillNames?: (projectScope: boolean) => Promise<string[]>;
|
|
40
|
+
readLockFile?: (lockFilePath: string) => LockFile;
|
|
41
|
+
writeManifest?: (outputPath: string, manifest: string) => void;
|
|
42
|
+
reportProgress?: (chunk: string) => void;
|
|
43
|
+
streamOutput?: boolean;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
function printHelp(): string {
|
|
47
|
+
return `\n${[
|
|
48
|
+
"Backup installed skills into skvlt.yaml.",
|
|
49
|
+
"",
|
|
50
|
+
helpHeading("Usage"),
|
|
51
|
+
" bunx skvlt backup [options]",
|
|
52
|
+
"",
|
|
53
|
+
helpHeading("Options"),
|
|
54
|
+
" --output <path> Output path for skvlt.yaml",
|
|
55
|
+
" --lock-file <path> Skills lock file to read sources from",
|
|
56
|
+
" --project-scope Backup project-scoped installs instead of global installs",
|
|
57
|
+
" --dry-run Print YAML to stdout instead of writing a file",
|
|
58
|
+
" --help Show this help",
|
|
59
|
+
"",
|
|
60
|
+
helpHeading("Notes"),
|
|
61
|
+
` - Output defaults to: ${formatCliPath(defaultManifestOptionPath)}`,
|
|
62
|
+
` - Global scope defaults to lock file: ${formatCliPath(defaultGlobalLockFilePath)}`,
|
|
63
|
+
" - Project scope currently requires --lock-file because the Skills CLI does not expose a discoverable project lock path here.",
|
|
64
|
+
"",
|
|
65
|
+
helpHeading("Examples"),
|
|
66
|
+
helpExample("bunx skvlt backup"),
|
|
67
|
+
helpExample(`bunx skvlt backup --output ${formatCliPath("./skvlt.yaml")}`),
|
|
68
|
+
helpExample(
|
|
69
|
+
`bunx skvlt backup --project-scope --lock-file ${formatCliPath("./skills-lock.json")}`,
|
|
70
|
+
),
|
|
71
|
+
helpFooter("https://github.com/xixu-me/skills-vault"),
|
|
72
|
+
].join("\n")}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function getInstalledSkillNames(
|
|
76
|
+
projectScope: boolean,
|
|
77
|
+
): Promise<string[]> {
|
|
78
|
+
return listInstalledSkillNames(projectScope);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function readLockFile(lockFilePath: string): LockFile {
|
|
82
|
+
if (!existsSync(lockFilePath)) {
|
|
83
|
+
throw new Error(`Lock file not found: ${formatCliPath(lockFilePath)}`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return JSON.parse(readFileSync(lockFilePath, "utf8")) as LockFile;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function resolveBackupErrorCode(message: string): string {
|
|
90
|
+
if (
|
|
91
|
+
message.startsWith("Unknown argument:") ||
|
|
92
|
+
message.includes("requires a path") ||
|
|
93
|
+
message.includes("--project-scope requires --lock-file")
|
|
94
|
+
) {
|
|
95
|
+
return "INVALID_ARGUMENT";
|
|
96
|
+
}
|
|
97
|
+
if (message.startsWith("Lock file not found:")) {
|
|
98
|
+
return "LOCK_FILE_NOT_FOUND";
|
|
99
|
+
}
|
|
100
|
+
if (message.startsWith("Unable to list installed skills")) {
|
|
101
|
+
return "SKILLS_LIST_FAILED";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return "BACKUP_FAILED";
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function formatBackupError(message: string, errorCode: string): string {
|
|
108
|
+
if (errorCode === "INVALID_ARGUMENT") {
|
|
109
|
+
return commandErrorPage(
|
|
110
|
+
message,
|
|
111
|
+
"bunx skvlt backup [options]",
|
|
112
|
+
`bunx skvlt backup --output ${formatCliPath("./skvlt.yaml")}`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return page(errorMessage(message));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Captures the currently installed skills into a portable manifest snapshot.
|
|
121
|
+
*/
|
|
122
|
+
export async function runBackup(
|
|
123
|
+
argv: string[],
|
|
124
|
+
dependencies: BackupDependencies = {},
|
|
125
|
+
): Promise<BackupRunResult> {
|
|
126
|
+
try {
|
|
127
|
+
const options = parseBackupArgs(argv);
|
|
128
|
+
if (options.help) {
|
|
129
|
+
return { exitCode: 0, stdout: printHelp(), stderr: "" };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const getInstalledSkillNamesImpl =
|
|
133
|
+
dependencies.getInstalledSkillNames ?? getInstalledSkillNames;
|
|
134
|
+
const readLockFileImpl = dependencies.readLockFile ?? readLockFile;
|
|
135
|
+
const writeManifestImpl = dependencies.writeManifest ?? writeFileSync;
|
|
136
|
+
const streamOutput = dependencies.streamOutput ?? false;
|
|
137
|
+
const reportProgress = dependencies.reportProgress ?? (() => {});
|
|
138
|
+
const outputLines: string[] = [];
|
|
139
|
+
let streamedOutput = false;
|
|
140
|
+
const appendLine = (line: string) => {
|
|
141
|
+
const formattedLine = line.includes("summary:")
|
|
142
|
+
? summaryLine(line)
|
|
143
|
+
: infoLine(line);
|
|
144
|
+
if (streamOutput) {
|
|
145
|
+
if (!streamedOutput) {
|
|
146
|
+
reportProgress("\n");
|
|
147
|
+
streamedOutput = true;
|
|
148
|
+
}
|
|
149
|
+
reportProgress(`${formattedLine}\n`);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
outputLines.push(formattedLine);
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
appendLine("Listing installed skills...");
|
|
157
|
+
const installedSkillNames = await getInstalledSkillNamesImpl(
|
|
158
|
+
options.projectScope,
|
|
159
|
+
);
|
|
160
|
+
appendLine("Reading lock file metadata...");
|
|
161
|
+
const lockFile = readLockFileImpl(options.lockFilePath);
|
|
162
|
+
appendLine("Building manifest...");
|
|
163
|
+
// Persist the scope so restore can round-trip project/global installs.
|
|
164
|
+
const manifest = buildManifest(
|
|
165
|
+
installedSkillNames,
|
|
166
|
+
lockFile,
|
|
167
|
+
options.projectScope ? "project" : "global",
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
if (options.dryRun) {
|
|
171
|
+
return {
|
|
172
|
+
exitCode: 0,
|
|
173
|
+
stdout: manifest.yaml,
|
|
174
|
+
stderr: "",
|
|
175
|
+
payload: {
|
|
176
|
+
dryRun: true,
|
|
177
|
+
outputPath: options.outputPath,
|
|
178
|
+
projectScope: options.projectScope,
|
|
179
|
+
totalSkills: manifest.totalSkills,
|
|
180
|
+
totalSources: manifest.totalSources,
|
|
181
|
+
manifest: manifest.yaml,
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
appendLine(`Writing manifest to ${formatCliPath(options.outputPath)}...`);
|
|
187
|
+
const outputDirectoryPath = dirname(resolve(options.outputPath));
|
|
188
|
+
if (!existsSync(outputDirectoryPath)) {
|
|
189
|
+
mkdirSync(outputDirectoryPath, { recursive: true });
|
|
190
|
+
}
|
|
191
|
+
writeManifestImpl(options.outputPath, manifest.yaml);
|
|
192
|
+
appendLine(
|
|
193
|
+
`Backup summary: ${manifest.totalSkills} skill(s) across ${manifest.totalSources} source(s) written to ${formatCliPath(options.outputPath)}`,
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
exitCode: 0,
|
|
198
|
+
stdout: streamOutput
|
|
199
|
+
? streamedOutput
|
|
200
|
+
? "\n"
|
|
201
|
+
: ""
|
|
202
|
+
: outputLines.length > 0
|
|
203
|
+
? page(outputLines.join("\n"))
|
|
204
|
+
: "",
|
|
205
|
+
stderr: "",
|
|
206
|
+
payload: {
|
|
207
|
+
dryRun: false,
|
|
208
|
+
outputPath: options.outputPath,
|
|
209
|
+
projectScope: options.projectScope,
|
|
210
|
+
totalSkills: manifest.totalSkills,
|
|
211
|
+
totalSources: manifest.totalSources,
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
} catch (error) {
|
|
215
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
216
|
+
const errorCode = resolveBackupErrorCode(message);
|
|
217
|
+
return {
|
|
218
|
+
exitCode: 1,
|
|
219
|
+
stdout: "",
|
|
220
|
+
stderr: formatBackupError(message, errorCode),
|
|
221
|
+
errorCode,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { parseCompletionArgs } from "../internal/args/parse-completion-args";
|
|
2
|
+
import {
|
|
3
|
+
commandErrorPage,
|
|
4
|
+
errorMessage,
|
|
5
|
+
helpExample,
|
|
6
|
+
helpFooter,
|
|
7
|
+
helpHeading,
|
|
8
|
+
page,
|
|
9
|
+
} from "../internal/cli/theme";
|
|
10
|
+
|
|
11
|
+
export type CompletionRunResult = {
|
|
12
|
+
exitCode: number;
|
|
13
|
+
stdout: string;
|
|
14
|
+
stderr: string;
|
|
15
|
+
errorCode?: string;
|
|
16
|
+
payload?: {
|
|
17
|
+
shell: string;
|
|
18
|
+
script: string;
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function printHelp(): string {
|
|
23
|
+
return `\n${[
|
|
24
|
+
"Print shell completion scripts for bunx skvlt.",
|
|
25
|
+
"",
|
|
26
|
+
helpHeading("Usage"),
|
|
27
|
+
" bunx skvlt completion <bash|zsh|powershell>",
|
|
28
|
+
"",
|
|
29
|
+
helpHeading("Options"),
|
|
30
|
+
" --help Show this help",
|
|
31
|
+
"",
|
|
32
|
+
helpHeading("Examples"),
|
|
33
|
+
helpExample("bunx skvlt completion bash"),
|
|
34
|
+
helpExample("bunx skvlt completion zsh"),
|
|
35
|
+
helpExample("bunx skvlt completion powershell"),
|
|
36
|
+
helpFooter("https://github.com/xixu-me/skills-vault"),
|
|
37
|
+
].join("\n")}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function bashScript(): string {
|
|
41
|
+
return `# bash completion for skvlt
|
|
42
|
+
_skvlt_completion() {
|
|
43
|
+
local current previous
|
|
44
|
+
current="\${COMP_WORDS[COMP_CWORD]}"
|
|
45
|
+
previous="\${COMP_WORDS[COMP_CWORD-1]}"
|
|
46
|
+
local commands="backup restore doctor completion"
|
|
47
|
+
local global_flags="--help --json --version"
|
|
48
|
+
|
|
49
|
+
if [[ \${COMP_CWORD} -eq 1 ]]; then
|
|
50
|
+
COMPREPLY=( $(compgen -W "$commands $global_flags" -- "$current") )
|
|
51
|
+
return 0
|
|
52
|
+
fi
|
|
53
|
+
|
|
54
|
+
case "\${COMP_WORDS[1]}" in
|
|
55
|
+
completion)
|
|
56
|
+
COMPREPLY=( $(compgen -W "bash zsh powershell --help" -- "$current") )
|
|
57
|
+
;;
|
|
58
|
+
backup|restore|doctor)
|
|
59
|
+
COMPREPLY=( $(compgen -W "--help --json" -- "$current") )
|
|
60
|
+
;;
|
|
61
|
+
esac
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
complete -F _skvlt_completion skvlt
|
|
65
|
+
`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function zshScript(): string {
|
|
69
|
+
return `#compdef skvlt
|
|
70
|
+
|
|
71
|
+
_skvlt_completion() {
|
|
72
|
+
local -a commands
|
|
73
|
+
commands=(
|
|
74
|
+
'backup:Backup installed skills into a manifest'
|
|
75
|
+
'restore:Restore skills from a manifest'
|
|
76
|
+
'doctor:Inspect the local environment and skill state'
|
|
77
|
+
'completion:Print shell completion scripts'
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
_arguments \
|
|
81
|
+
'1:command:->command' \
|
|
82
|
+
'*::arg:->args'
|
|
83
|
+
|
|
84
|
+
case $state in
|
|
85
|
+
command)
|
|
86
|
+
_describe 'command' commands
|
|
87
|
+
;;
|
|
88
|
+
args)
|
|
89
|
+
if [[ $words[2] == completion ]]; then
|
|
90
|
+
_values 'shell' bash zsh powershell
|
|
91
|
+
else
|
|
92
|
+
_values 'flag' --help --json --version
|
|
93
|
+
fi
|
|
94
|
+
;;
|
|
95
|
+
esac
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
_skvlt_completion "$@"
|
|
99
|
+
`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function powershellScript(): string {
|
|
103
|
+
return `Register-ArgumentCompleter -Native -CommandName skvlt -ScriptBlock {
|
|
104
|
+
param($wordToComplete, $commandAst, $cursorPosition)
|
|
105
|
+
|
|
106
|
+
$commands = @('backup', 'restore', 'doctor', 'completion', '--help', '--json', '--version')
|
|
107
|
+
$completionText = $commandAst.CommandElements | ForEach-Object { $_.Extent.Text }
|
|
108
|
+
|
|
109
|
+
if ($completionText.Count -le 2) {
|
|
110
|
+
$commands |
|
|
111
|
+
Where-Object { $_ -like "$wordToComplete*" } |
|
|
112
|
+
ForEach-Object {
|
|
113
|
+
[System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)
|
|
114
|
+
}
|
|
115
|
+
return
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if ($completionText[1] -eq 'completion') {
|
|
119
|
+
@('bash', 'zsh', 'powershell') |
|
|
120
|
+
Where-Object { $_ -like "$wordToComplete*" } |
|
|
121
|
+
ForEach-Object {
|
|
122
|
+
[System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function buildCompletionScript(shell: string): string {
|
|
130
|
+
switch (shell) {
|
|
131
|
+
case "bash":
|
|
132
|
+
return bashScript();
|
|
133
|
+
case "zsh":
|
|
134
|
+
return zshScript();
|
|
135
|
+
case "powershell":
|
|
136
|
+
return powershellScript();
|
|
137
|
+
default:
|
|
138
|
+
throw new Error(`Unsupported shell: ${shell}`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function resolveCompletionErrorCode(message: string): string {
|
|
143
|
+
if (
|
|
144
|
+
message.startsWith("Unsupported shell:") ||
|
|
145
|
+
message.startsWith("Unknown argument:")
|
|
146
|
+
) {
|
|
147
|
+
return "INVALID_ARGUMENT";
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return "COMPLETION_FAILED";
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function formatCompletionError(message: string, errorCode: string): string {
|
|
154
|
+
if (errorCode === "INVALID_ARGUMENT") {
|
|
155
|
+
return commandErrorPage(
|
|
156
|
+
message,
|
|
157
|
+
"bunx skvlt completion <bash|zsh|powershell>",
|
|
158
|
+
"bunx skvlt completion bash",
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return page(errorMessage(message));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Prints the requested shell completion script or top-level completion help.
|
|
167
|
+
*/
|
|
168
|
+
export async function runCompletion(
|
|
169
|
+
argv: string[],
|
|
170
|
+
): Promise<CompletionRunResult> {
|
|
171
|
+
try {
|
|
172
|
+
const options = parseCompletionArgs(argv);
|
|
173
|
+
if (options.help || !options.shell) {
|
|
174
|
+
return { exitCode: 0, stdout: printHelp(), stderr: "" };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const script = buildCompletionScript(options.shell);
|
|
178
|
+
return {
|
|
179
|
+
exitCode: 0,
|
|
180
|
+
stdout: script,
|
|
181
|
+
stderr: "",
|
|
182
|
+
payload: {
|
|
183
|
+
shell: options.shell,
|
|
184
|
+
script,
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
} catch (error) {
|
|
188
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
189
|
+
const errorCode = resolveCompletionErrorCode(message);
|
|
190
|
+
return {
|
|
191
|
+
exitCode: 1,
|
|
192
|
+
stdout: "",
|
|
193
|
+
stderr: formatCompletionError(message, errorCode),
|
|
194
|
+
errorCode,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import {
|
|
3
|
+
diffGlobalSkillState,
|
|
4
|
+
readInstalledGlobalSkillNames,
|
|
5
|
+
readTrackedGlobalSkillNames,
|
|
6
|
+
} from "../internal/install/global-skill-state";
|
|
7
|
+
import { BunxResult, runBunx } from "../internal/process/run-bunx";
|
|
8
|
+
import { parseDoctorArgs } from "../internal/args/parse-doctor-args";
|
|
9
|
+
import {
|
|
10
|
+
defaultGlobalLockFilePath,
|
|
11
|
+
defaultGlobalSkillsPath,
|
|
12
|
+
} from "../internal/paths/defaults";
|
|
13
|
+
import {
|
|
14
|
+
commandErrorPage,
|
|
15
|
+
errorMessage,
|
|
16
|
+
helpExample,
|
|
17
|
+
helpFooter,
|
|
18
|
+
helpHeading,
|
|
19
|
+
page,
|
|
20
|
+
statusTag,
|
|
21
|
+
summaryLine,
|
|
22
|
+
} from "../internal/cli/theme";
|
|
23
|
+
import { formatCliPath } from "../internal/paths/format-cli-path";
|
|
24
|
+
|
|
25
|
+
type DoctorCheckStatus = "ok" | "warn" | "fail";
|
|
26
|
+
|
|
27
|
+
type DoctorCheck = {
|
|
28
|
+
name: string;
|
|
29
|
+
status: DoctorCheckStatus;
|
|
30
|
+
detail: string;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type DoctorRunResult = {
|
|
34
|
+
exitCode: number;
|
|
35
|
+
stdout: string;
|
|
36
|
+
stderr: string;
|
|
37
|
+
errorCode?: string;
|
|
38
|
+
payload?: {
|
|
39
|
+
manifestPath: string;
|
|
40
|
+
okCount: number;
|
|
41
|
+
warnCount: number;
|
|
42
|
+
failCount: number;
|
|
43
|
+
checks: DoctorCheck[];
|
|
44
|
+
};
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export type DoctorDependencies = {
|
|
48
|
+
getBunVersion?: () => string;
|
|
49
|
+
checkSkillsCli?: () => Promise<{ ok: boolean; detail: string }>;
|
|
50
|
+
manifestExists?: (path: string) => boolean;
|
|
51
|
+
lockFileExists?: () => boolean;
|
|
52
|
+
skillsDirectoryExists?: () => boolean;
|
|
53
|
+
readTrackedGlobalSkillNames?: () => Promise<Set<string>>;
|
|
54
|
+
readInstalledGlobalSkillNames?: () => Promise<Set<string>>;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
function printHelp(): string {
|
|
58
|
+
return `\n${[
|
|
59
|
+
"Inspect the local Skills Vault environment and installation state.",
|
|
60
|
+
"",
|
|
61
|
+
helpHeading("Usage"),
|
|
62
|
+
" bunx skvlt doctor [options]",
|
|
63
|
+
"",
|
|
64
|
+
helpHeading("Options"),
|
|
65
|
+
" --manifest <path> Manifest path to inspect",
|
|
66
|
+
" --help Show this help",
|
|
67
|
+
"",
|
|
68
|
+
helpHeading("Examples"),
|
|
69
|
+
helpExample("bunx skvlt doctor"),
|
|
70
|
+
helpExample("bunx skvlt doctor --manifest ./skvlt.yaml"),
|
|
71
|
+
helpFooter("https://github.com/xixu-me/skills-vault"),
|
|
72
|
+
].join("\n")}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function checkSkillsCli(): Promise<{ ok: boolean; detail: string }> {
|
|
76
|
+
const result: BunxResult = await runBunx(["skills", "--help"]);
|
|
77
|
+
if (result.exitCode !== 0) {
|
|
78
|
+
return {
|
|
79
|
+
ok: false,
|
|
80
|
+
detail: (result.stderr || result.stdout).trim() || "bunx skills failed",
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return { ok: true, detail: "skills CLI available" };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function formatCheck(check: DoctorCheck) {
|
|
88
|
+
return `${statusTag(check.status)} ${check.name}: ${check.detail}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function summarizeChecks(checks: DoctorCheck[]) {
|
|
92
|
+
return checks.reduce(
|
|
93
|
+
(summary, check) => {
|
|
94
|
+
summary[`${check.status}Count` as const] += 1;
|
|
95
|
+
return summary;
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
okCount: 0,
|
|
99
|
+
warnCount: 0,
|
|
100
|
+
failCount: 0,
|
|
101
|
+
},
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function formatDoctorError(message: string): string {
|
|
106
|
+
if (
|
|
107
|
+
message.startsWith("Unknown argument:") ||
|
|
108
|
+
message.includes("requires a value") ||
|
|
109
|
+
message.includes("requires a path")
|
|
110
|
+
) {
|
|
111
|
+
return commandErrorPage(
|
|
112
|
+
message,
|
|
113
|
+
"bunx skvlt doctor [options]",
|
|
114
|
+
"bunx skvlt doctor --manifest ./skvlt.yaml",
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return page(errorMessage(message));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Reports whether the local Bun, skills CLI, manifest, and global skill state look healthy.
|
|
123
|
+
*/
|
|
124
|
+
export async function runDoctor(
|
|
125
|
+
argv: string[],
|
|
126
|
+
dependencies: DoctorDependencies = {},
|
|
127
|
+
): Promise<DoctorRunResult> {
|
|
128
|
+
try {
|
|
129
|
+
const options = parseDoctorArgs(argv);
|
|
130
|
+
if (options.help) {
|
|
131
|
+
return { exitCode: 0, stdout: printHelp(), stderr: "" };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const getBunVersion = dependencies.getBunVersion ?? (() => Bun.version);
|
|
135
|
+
const checkSkillsCliImpl = dependencies.checkSkillsCli ?? checkSkillsCli;
|
|
136
|
+
const manifestExists = dependencies.manifestExists ?? existsSync;
|
|
137
|
+
const lockFileExists =
|
|
138
|
+
dependencies.lockFileExists ??
|
|
139
|
+
(() => existsSync(defaultGlobalLockFilePath));
|
|
140
|
+
const skillsDirectoryExists =
|
|
141
|
+
dependencies.skillsDirectoryExists ??
|
|
142
|
+
(() => existsSync(defaultGlobalSkillsPath));
|
|
143
|
+
const readTrackedGlobalSkillNamesImpl =
|
|
144
|
+
dependencies.readTrackedGlobalSkillNames ?? readTrackedGlobalSkillNames;
|
|
145
|
+
const readInstalledGlobalSkillNamesImpl =
|
|
146
|
+
dependencies.readInstalledGlobalSkillNames ??
|
|
147
|
+
readInstalledGlobalSkillNames;
|
|
148
|
+
|
|
149
|
+
const checks: DoctorCheck[] = [];
|
|
150
|
+
checks.push({
|
|
151
|
+
name: "Bun runtime",
|
|
152
|
+
status: "ok",
|
|
153
|
+
detail: getBunVersion(),
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const skillsCliStatus = await checkSkillsCliImpl();
|
|
157
|
+
checks.push({
|
|
158
|
+
name: "Skills CLI",
|
|
159
|
+
status: skillsCliStatus.ok ? "ok" : "fail",
|
|
160
|
+
detail: skillsCliStatus.detail,
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
checks.push({
|
|
164
|
+
name: "Manifest",
|
|
165
|
+
status: manifestExists(options.manifestPath) ? "ok" : "warn",
|
|
166
|
+
detail: manifestExists(options.manifestPath)
|
|
167
|
+
? formatCliPath(options.manifestPath)
|
|
168
|
+
: `missing: ${formatCliPath(options.manifestPath)}`,
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const hasLockFile = lockFileExists();
|
|
172
|
+
checks.push({
|
|
173
|
+
name: "Global lock file",
|
|
174
|
+
status: hasLockFile ? "ok" : "warn",
|
|
175
|
+
detail: hasLockFile
|
|
176
|
+
? formatCliPath(defaultGlobalLockFilePath)
|
|
177
|
+
: `missing: ${formatCliPath(defaultGlobalLockFilePath)}`,
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
const hasSkillsDirectory = skillsDirectoryExists();
|
|
181
|
+
if (!hasSkillsDirectory) {
|
|
182
|
+
checks.push({
|
|
183
|
+
name: "Global skill state",
|
|
184
|
+
status: "warn",
|
|
185
|
+
detail: `skills directory missing: ${formatCliPath(defaultGlobalSkillsPath)}`,
|
|
186
|
+
});
|
|
187
|
+
} else if (!hasLockFile) {
|
|
188
|
+
checks.push({
|
|
189
|
+
name: "Global skill state",
|
|
190
|
+
status: "warn",
|
|
191
|
+
detail: "lock file missing; skipped consistency comparison",
|
|
192
|
+
});
|
|
193
|
+
} else {
|
|
194
|
+
const [trackedSkillNames, installedSkillNames] = await Promise.all([
|
|
195
|
+
readTrackedGlobalSkillNamesImpl(),
|
|
196
|
+
readInstalledGlobalSkillNamesImpl(),
|
|
197
|
+
]);
|
|
198
|
+
// Report both mismatch directions so stale lock entries and missing lock
|
|
199
|
+
// entries show up as separate, actionable warnings.
|
|
200
|
+
const { missingFromLock, missingFromDirectory } = diffGlobalSkillState(
|
|
201
|
+
trackedSkillNames,
|
|
202
|
+
installedSkillNames,
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
checks.push({
|
|
206
|
+
name: "Global skill state",
|
|
207
|
+
status:
|
|
208
|
+
missingFromLock.length > 0 || missingFromDirectory.length > 0
|
|
209
|
+
? "warn"
|
|
210
|
+
: "ok",
|
|
211
|
+
detail:
|
|
212
|
+
missingFromLock.length > 0 || missingFromDirectory.length > 0
|
|
213
|
+
? [
|
|
214
|
+
missingFromLock.length > 0
|
|
215
|
+
? `${missingFromLock.length} installed missing from lock`
|
|
216
|
+
: "",
|
|
217
|
+
missingFromDirectory.length > 0
|
|
218
|
+
? `${missingFromDirectory.length} tracked missing from skills directory`
|
|
219
|
+
: "",
|
|
220
|
+
]
|
|
221
|
+
.filter(Boolean)
|
|
222
|
+
.join("; ")
|
|
223
|
+
: `${installedSkillNames.size} installed / ${trackedSkillNames.size} tracked`,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const summary = summarizeChecks(checks);
|
|
228
|
+
const stdout = page(
|
|
229
|
+
[
|
|
230
|
+
...checks.map(formatCheck),
|
|
231
|
+
summaryLine(
|
|
232
|
+
`Doctor summary: ${summary.okCount} ok, ${summary.warnCount} warn, ${summary.failCount} fail.`,
|
|
233
|
+
),
|
|
234
|
+
].join("\n"),
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
return {
|
|
238
|
+
exitCode: summary.failCount > 0 ? 1 : 0,
|
|
239
|
+
stdout,
|
|
240
|
+
stderr: "",
|
|
241
|
+
errorCode: summary.failCount > 0 ? "DOCTOR_FAILED" : undefined,
|
|
242
|
+
payload: {
|
|
243
|
+
manifestPath: options.manifestPath,
|
|
244
|
+
...summary,
|
|
245
|
+
checks,
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
} catch (error) {
|
|
249
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
250
|
+
return {
|
|
251
|
+
exitCode: 1,
|
|
252
|
+
stdout: "",
|
|
253
|
+
stderr: formatDoctorError(message),
|
|
254
|
+
errorCode: "DOCTOR_FAILED",
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
}
|