@devkitvault/recall 1.2.9 → 1.2.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/dist/commands/import.js +155 -45
- package/dist/lib/config.js +1 -1
- package/dist/lib/shell-history.js +94 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -25,17 +25,18 @@ recall doctor
|
|
|
25
25
|
|
|
26
26
|
## Quick start (local vault)
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
Local save / list / search / run work **without an account**. Auth is only needed for **sync**, **Ask** (Pro/Team), and team features.
|
|
29
29
|
|
|
30
30
|
```sh
|
|
31
31
|
recall save "docker compose up -d" --name docker --tags docker
|
|
32
32
|
recall save --last -n useful -t docker
|
|
33
|
+
recall import history
|
|
33
34
|
recall list
|
|
34
35
|
recall search docker
|
|
35
36
|
recall run docker
|
|
36
37
|
```
|
|
37
38
|
|
|
38
|
-
|
|
39
|
+
`recall import history` reads your shell history file (zsh/bash/PowerShell), lets you pick commands, and saves them locally. It does **not** install a keylogger or live hook.
|
|
39
40
|
|
|
40
41
|
`recall save --last` (or `-L`) reads the last line from zsh / bash / PowerShell history. Not supported in Windows `cmd.exe`. On bash, if the command is missing: `history -a && recall save --last`.
|
|
41
42
|
|
package/dist/commands/import.js
CHANGED
|
@@ -7,19 +7,22 @@ exports.importCommand = void 0;
|
|
|
7
7
|
const chalk_1 = __importDefault(require("chalk"));
|
|
8
8
|
const commander_1 = require("commander");
|
|
9
9
|
const fs_1 = __importDefault(require("fs"));
|
|
10
|
-
const
|
|
10
|
+
const inquirer_1 = __importDefault(require("inquirer"));
|
|
11
11
|
const path_1 = __importDefault(require("path"));
|
|
12
|
-
const
|
|
13
|
-
const
|
|
12
|
+
const local_vault_1 = require("../lib/local-vault");
|
|
13
|
+
const tags_1 = require("../lib/tags");
|
|
14
|
+
const shell_history_1 = require("../lib/shell-history");
|
|
14
15
|
function parseJSON(content) {
|
|
15
16
|
const data = JSON.parse(content);
|
|
16
17
|
if (!Array.isArray(data))
|
|
17
18
|
throw new Error('JSON must be an array of commands');
|
|
18
19
|
return data.map((item) => ({
|
|
19
|
-
command: item.command,
|
|
20
|
-
name: item.name,
|
|
21
|
-
tags: item.tags
|
|
22
|
-
|
|
20
|
+
command: String(item.command ?? ''),
|
|
21
|
+
name: typeof item.name === 'string' ? item.name : undefined,
|
|
22
|
+
tags: Array.isArray(item.tags)
|
|
23
|
+
? item.tags.filter((t) => typeof t === 'string')
|
|
24
|
+
: undefined,
|
|
25
|
+
})).filter((e) => e.command.trim());
|
|
23
26
|
}
|
|
24
27
|
function parseSh(content) {
|
|
25
28
|
const lines = content.split('\n');
|
|
@@ -28,14 +31,13 @@ function parseSh(content) {
|
|
|
28
31
|
let currentTags = [];
|
|
29
32
|
for (const raw of lines) {
|
|
30
33
|
const line = raw.trim();
|
|
31
|
-
// skip empty lines and shebang
|
|
32
34
|
if (!line || line.startsWith('#!'))
|
|
33
35
|
continue;
|
|
34
36
|
if (line.startsWith('# tags:')) {
|
|
35
37
|
currentTags = line
|
|
36
38
|
.replace('# tags:', '')
|
|
37
39
|
.split(',')
|
|
38
|
-
.map(t => t.trim())
|
|
40
|
+
.map((t) => t.trim())
|
|
39
41
|
.filter(Boolean);
|
|
40
42
|
continue;
|
|
41
43
|
}
|
|
@@ -43,24 +45,59 @@ function parseSh(content) {
|
|
|
43
45
|
currentName = line.replace('#', '').trim();
|
|
44
46
|
continue;
|
|
45
47
|
}
|
|
46
|
-
// it's a command
|
|
47
48
|
entries.push({
|
|
48
49
|
command: line,
|
|
49
50
|
name: currentName,
|
|
50
|
-
tags: currentTags,
|
|
51
|
+
tags: currentTags.length ? currentTags : undefined,
|
|
51
52
|
});
|
|
52
|
-
// reset
|
|
53
53
|
currentName = undefined;
|
|
54
54
|
currentTags = [];
|
|
55
55
|
}
|
|
56
56
|
return entries;
|
|
57
57
|
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
58
|
+
function commandAlreadyLocal(command, name) {
|
|
59
|
+
if (name && (0, local_vault_1.findLocalByName)(name))
|
|
60
|
+
return true;
|
|
61
|
+
return (0, local_vault_1.listLocalCommands)().some((c) => c.command === command);
|
|
62
|
+
}
|
|
63
|
+
function suggestName(command, used) {
|
|
64
|
+
const base = command
|
|
65
|
+
.replace(/[^a-zA-Z0-9]+/g, '-')
|
|
66
|
+
.replace(/^-|-$/g, '')
|
|
67
|
+
.toLowerCase()
|
|
68
|
+
.slice(0, 32) || 'cmd';
|
|
69
|
+
let name = base;
|
|
70
|
+
let n = 2;
|
|
71
|
+
while (used.has(name) || (0, local_vault_1.findLocalByName)(name)) {
|
|
72
|
+
name = `${base.slice(0, 28)}-${n}`;
|
|
73
|
+
n++;
|
|
74
|
+
}
|
|
75
|
+
used.add(name);
|
|
76
|
+
return name;
|
|
77
|
+
}
|
|
78
|
+
async function importEntries(entries, opts) {
|
|
79
|
+
let imported = 0;
|
|
80
|
+
let skipped = 0;
|
|
81
|
+
const usedNames = new Set();
|
|
82
|
+
for (const entry of entries) {
|
|
83
|
+
if (opts.skipDuplicates && commandAlreadyLocal(entry.command, entry.name)) {
|
|
84
|
+
skipped++;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const name = entry.name
|
|
88
|
+
?? (opts.nameThem ? suggestName(entry.command, usedNames) : undefined);
|
|
89
|
+
if (name)
|
|
90
|
+
usedNames.add(name);
|
|
91
|
+
(0, local_vault_1.saveLocalCommand)({
|
|
92
|
+
command: entry.command,
|
|
93
|
+
name,
|
|
94
|
+
tags: entry.tags,
|
|
95
|
+
});
|
|
96
|
+
imported++;
|
|
97
|
+
}
|
|
98
|
+
return { imported, skipped };
|
|
99
|
+
}
|
|
100
|
+
async function importFromFile(file, opts) {
|
|
64
101
|
const filePath = path_1.default.resolve(file);
|
|
65
102
|
if (!fs_1.default.existsSync(filePath)) {
|
|
66
103
|
console.error(chalk_1.default.red(`\n File not found: ${filePath}\n`));
|
|
@@ -77,7 +114,6 @@ exports.importCommand = new commander_1.Command('import')
|
|
|
77
114
|
entries = parseSh(content);
|
|
78
115
|
}
|
|
79
116
|
else {
|
|
80
|
-
// try JSON first, then sh
|
|
81
117
|
try {
|
|
82
118
|
entries = parseJSON(content);
|
|
83
119
|
}
|
|
@@ -87,40 +123,114 @@ exports.importCommand = new commander_1.Command('import')
|
|
|
87
123
|
}
|
|
88
124
|
}
|
|
89
125
|
catch (err) {
|
|
90
|
-
|
|
126
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
127
|
+
console.error(chalk_1.default.red(`\n Failed to parse file: ${message}\n`));
|
|
91
128
|
process.exit(1);
|
|
92
129
|
}
|
|
93
130
|
if (!entries.length) {
|
|
94
131
|
console.log(chalk_1.default.dim('\n No commands found in file.\n'));
|
|
95
132
|
return;
|
|
96
133
|
}
|
|
97
|
-
console.log(chalk_1.default.dim(`\n Found ${entries.length} command${entries.length === 1 ? '' : 's'}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
134
|
+
console.log(chalk_1.default.dim(`\n Found ${entries.length} command${entries.length === 1 ? '' : 's'} in file\n`));
|
|
135
|
+
const { imported, skipped } = await importEntries(entries, {
|
|
136
|
+
skipDuplicates: opts.skipDuplicates,
|
|
137
|
+
nameThem: false,
|
|
138
|
+
});
|
|
139
|
+
console.log(` ${chalk_1.default.green(`${imported} imported`)} ${chalk_1.default.yellow(`${skipped} skipped`)}`);
|
|
140
|
+
console.log(chalk_1.default.dim(` Vault: ${local_vault_1.VAULT_FILE}\n`));
|
|
141
|
+
}
|
|
142
|
+
exports.importCommand = new commander_1.Command('import')
|
|
143
|
+
.description('Import commands into your local vault')
|
|
144
|
+
.argument('[file]', 'JSON or shell script to import (or use: recall import history)')
|
|
145
|
+
.option('-s, --skip-duplicates', 'Skip commands that already exist locally', true)
|
|
146
|
+
.addHelpText('after', `
|
|
147
|
+
Examples:
|
|
148
|
+
$ recall import history
|
|
149
|
+
$ recall import history -n 20 -y
|
|
150
|
+
$ recall import ./backup.json
|
|
151
|
+
`)
|
|
152
|
+
.action(async (file, opts) => {
|
|
153
|
+
if (!file) {
|
|
154
|
+
console.log(chalk_1.default.dim('\n Usage:'));
|
|
155
|
+
console.log(chalk_1.default.dim(' recall import history # from shell history (curated)'));
|
|
156
|
+
console.log(chalk_1.default.dim(' recall import <file> # from JSON or .sh\n'));
|
|
157
|
+
process.exit(1);
|
|
158
|
+
}
|
|
159
|
+
await importFromFile(file, opts);
|
|
160
|
+
});
|
|
161
|
+
exports.importCommand
|
|
162
|
+
.command('history')
|
|
163
|
+
.description('Import curated commands from shell history into the local vault (not live capture)')
|
|
164
|
+
.option('-n, --limit <count>', 'Max unique recent commands to consider', '40')
|
|
165
|
+
.option('-y, --yes', 'Import all candidates without prompting')
|
|
166
|
+
.option('-i, --interactive', 'Choose which commands to import (default unless --yes)')
|
|
167
|
+
.option('--dry-run', 'Show candidates only; do not write')
|
|
168
|
+
.option('-t, --tags <tags>', 'Tags applied to imported commands')
|
|
169
|
+
.option('--min-length <n>', 'Skip commands shorter than this', '3')
|
|
170
|
+
.option('--no-names', 'Do not auto-generate names')
|
|
171
|
+
.option('--no-skip-duplicates', 'Import even if the same command text already exists')
|
|
172
|
+
.action(async (opts) => {
|
|
173
|
+
const limit = Math.max(1, parseInt(String(opts.limit), 10) || 40);
|
|
174
|
+
const minLength = Math.max(1, parseInt(String(opts.minLength), 10) || 3);
|
|
175
|
+
const tags = (0, tags_1.parseTags)(opts.tags);
|
|
176
|
+
let commands;
|
|
177
|
+
let source;
|
|
178
|
+
let shell;
|
|
179
|
+
try {
|
|
180
|
+
const result = (0, shell_history_1.readHistoryCommands)({ limit, minLength });
|
|
181
|
+
commands = result.commands;
|
|
182
|
+
source = result.source;
|
|
183
|
+
shell = result.shell;
|
|
184
|
+
}
|
|
185
|
+
catch (err) {
|
|
186
|
+
if (err instanceof shell_history_1.LastCommandError) {
|
|
187
|
+
console.log(chalk_1.default.red(`\n ${err.message}\n`));
|
|
188
|
+
process.exit(1);
|
|
121
189
|
}
|
|
190
|
+
throw err;
|
|
122
191
|
}
|
|
123
192
|
console.log();
|
|
124
|
-
console.log(
|
|
193
|
+
console.log(chalk_1.default.dim(` Shell: ${shell} · ${source}`));
|
|
194
|
+
console.log(chalk_1.default.dim(` ${commands.length} unique candidate${commands.length === 1 ? '' : 's'} (newest first)\n`));
|
|
195
|
+
let selected = commands;
|
|
196
|
+
const interactive = !opts.yes && (opts.interactive || !opts.dryRun);
|
|
197
|
+
if (opts.yes) {
|
|
198
|
+
selected = commands;
|
|
199
|
+
}
|
|
200
|
+
else if (interactive && !opts.dryRun) {
|
|
201
|
+
const { picked } = await inquirer_1.default.prompt([
|
|
202
|
+
{
|
|
203
|
+
type: 'checkbox',
|
|
204
|
+
name: 'picked',
|
|
205
|
+
message: 'Select commands to save locally',
|
|
206
|
+
pageSize: 16,
|
|
207
|
+
choices: commands.map((c) => ({
|
|
208
|
+
name: c.length > 80 ? `${c.slice(0, 77)}…` : c,
|
|
209
|
+
value: c,
|
|
210
|
+
checked: false,
|
|
211
|
+
})),
|
|
212
|
+
},
|
|
213
|
+
]);
|
|
214
|
+
selected = picked;
|
|
215
|
+
if (!selected.length) {
|
|
216
|
+
console.log(chalk_1.default.dim('\n Nothing selected.\n'));
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
if (opts.dryRun) {
|
|
221
|
+
for (const [i, c] of selected.entries()) {
|
|
222
|
+
console.log(` ${chalk_1.default.dim(String(i + 1).padStart(2))}. ${c}`);
|
|
223
|
+
}
|
|
224
|
+
console.log(chalk_1.default.dim(`\n Dry run — nothing written. Drop --dry-run to import.\n`));
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
const entries = selected.map((command) => ({ command, tags }));
|
|
228
|
+
const { imported, skipped } = await importEntries(entries, {
|
|
229
|
+
skipDuplicates: opts.skipDuplicates !== false,
|
|
230
|
+
nameThem: opts.names !== false,
|
|
231
|
+
});
|
|
125
232
|
console.log();
|
|
233
|
+
console.log(` ${chalk_1.default.green(`${imported} imported`)} ${chalk_1.default.yellow(`${skipped} skipped`)}`);
|
|
234
|
+
console.log(chalk_1.default.dim(` Vault: ${local_vault_1.VAULT_FILE}`));
|
|
235
|
+
console.log(chalk_1.default.dim(' Sync to cloud (Pro): recall sync\n'));
|
|
126
236
|
});
|
package/dist/lib/config.js
CHANGED
|
@@ -11,7 +11,7 @@ const os_1 = __importDefault(require("os"));
|
|
|
11
11
|
const path_1 = __importDefault(require("path"));
|
|
12
12
|
const CONFIG_DIR = path_1.default.join(os_1.default.homedir(), '.recall');
|
|
13
13
|
const CONFIG_FILE = path_1.default.join(CONFIG_DIR, 'config.json');
|
|
14
|
-
exports.APP_VERSION = '1.2.
|
|
14
|
+
exports.APP_VERSION = '1.2.10';
|
|
15
15
|
exports.ENVIRONMENTS = {
|
|
16
16
|
production: 'https://api.devkitvault.com',
|
|
17
17
|
local: 'http://127.0.0.1:3001',
|
|
@@ -5,6 +5,10 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.LastCommandError = void 0;
|
|
7
7
|
exports.isSelfSaveCommand = isSelfSaveCommand;
|
|
8
|
+
exports.isSkippedHistoryCommand = isSkippedHistoryCommand;
|
|
9
|
+
exports.normalizeHistoryLine = normalizeHistoryLine;
|
|
10
|
+
exports.extractSaveableCommands = extractSaveableCommands;
|
|
11
|
+
exports.readHistoryCommands = readHistoryCommands;
|
|
8
12
|
exports.stripZshExtendedPrefix = stripZshExtendedPrefix;
|
|
9
13
|
exports.findLastSaveableCommand = findLastSaveableCommand;
|
|
10
14
|
exports.detectShellKind = detectShellKind;
|
|
@@ -19,6 +23,96 @@ function isSelfSaveCommand(line) {
|
|
|
19
23
|
const t = line.trim();
|
|
20
24
|
return /^(recall|rec)\s+save(\s|$)/.test(t);
|
|
21
25
|
}
|
|
26
|
+
/** Skip trivial / meta lines when bulk-importing history. */
|
|
27
|
+
function isSkippedHistoryCommand(line) {
|
|
28
|
+
const t = line.trim();
|
|
29
|
+
if (!t)
|
|
30
|
+
return true;
|
|
31
|
+
if (isSelfSaveCommand(t))
|
|
32
|
+
return true;
|
|
33
|
+
if (/^(recall|rec)(\s|$)/.test(t))
|
|
34
|
+
return true;
|
|
35
|
+
if (t.length < 2)
|
|
36
|
+
return true;
|
|
37
|
+
const first = t.split(/\s+/)[0]?.toLowerCase() ?? '';
|
|
38
|
+
const trivial = new Set([
|
|
39
|
+
'cd', 'ls', 'll', 'la', 'pwd', 'clear', 'cls', 'exit', 'logout',
|
|
40
|
+
'history', 'true', 'false', ':',
|
|
41
|
+
]);
|
|
42
|
+
if (trivial.has(first) && t.split(/\s+/).length <= 2)
|
|
43
|
+
return true;
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
/** Normalize one history file line into a bare command, or null if not usable. */
|
|
47
|
+
function normalizeHistoryLine(rawLine) {
|
|
48
|
+
let raw = rawLine.replace(/\r$/, '');
|
|
49
|
+
if (!raw.trim())
|
|
50
|
+
return null;
|
|
51
|
+
if (/^#\d+$/.test(raw.trim()))
|
|
52
|
+
return null;
|
|
53
|
+
raw = stripZshExtendedPrefix(raw).trim();
|
|
54
|
+
if (!raw)
|
|
55
|
+
return null;
|
|
56
|
+
return raw;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Walk history lines (oldest → newest) and return unique saveable commands, newest first.
|
|
60
|
+
*/
|
|
61
|
+
function extractSaveableCommands(lines, opts = {}) {
|
|
62
|
+
const minLength = opts.minLength ?? 3;
|
|
63
|
+
const seen = new Set();
|
|
64
|
+
const out = [];
|
|
65
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
66
|
+
const cmd = normalizeHistoryLine(lines[i] ?? '');
|
|
67
|
+
if (!cmd)
|
|
68
|
+
continue;
|
|
69
|
+
if (cmd.length < minLength)
|
|
70
|
+
continue;
|
|
71
|
+
if (isSkippedHistoryCommand(cmd))
|
|
72
|
+
continue;
|
|
73
|
+
if (seen.has(cmd))
|
|
74
|
+
continue;
|
|
75
|
+
seen.add(cmd);
|
|
76
|
+
out.push(cmd);
|
|
77
|
+
if (opts.limit !== undefined && out.length >= opts.limit)
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
function readHistoryCommands(opts = {}) {
|
|
83
|
+
const env = opts.env ?? process.env;
|
|
84
|
+
const platform = opts.platform ?? process.platform;
|
|
85
|
+
const home = opts.home ?? os_1.default.homedir();
|
|
86
|
+
const shell = detectShellKind(env, platform);
|
|
87
|
+
if (shell === 'cmd') {
|
|
88
|
+
throw new LastCommandError('import history is not supported in cmd.exe. Use PowerShell, Git Bash, or WSL.', 'unsupported');
|
|
89
|
+
}
|
|
90
|
+
if (shell === 'unknown') {
|
|
91
|
+
throw new LastCommandError('Could not detect your shell. Use zsh, bash, or PowerShell.', 'unsupported');
|
|
92
|
+
}
|
|
93
|
+
const files = historyFileCandidates(shell, env, home);
|
|
94
|
+
for (const file of files) {
|
|
95
|
+
try {
|
|
96
|
+
if (!fs_1.default.existsSync(file))
|
|
97
|
+
continue;
|
|
98
|
+
const text = fs_1.default.readFileSync(file, 'utf8');
|
|
99
|
+
const commands = extractSaveableCommands(text.split('\n'), {
|
|
100
|
+
limit: opts.limit,
|
|
101
|
+
minLength: opts.minLength,
|
|
102
|
+
});
|
|
103
|
+
if (commands.length) {
|
|
104
|
+
return { commands, source: file, shell };
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
// try next
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const tip = shell === 'bash'
|
|
112
|
+
? ' If history looks empty, try: history -a && recall import history'
|
|
113
|
+
: '';
|
|
114
|
+
throw new LastCommandError(`No importable commands found in shell history.${tip}`, 'not_found');
|
|
115
|
+
}
|
|
22
116
|
/** Strip zsh extended-history prefix `: <unix>:<duration>;`. */
|
|
23
117
|
function stripZshExtendedPrefix(line) {
|
|
24
118
|
const m = line.match(/^:\s*\d+:\d+;(.*)$/);
|