@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 CHANGED
@@ -25,17 +25,18 @@ recall doctor
25
25
 
26
26
  ## Quick start (local vault)
27
27
 
28
- Free is **fully local** — save / list / search / run on your machine with **no account**. Cloud **sync** and **Ask** are Pro/Team.
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
- Commands live in `~/.recall/commands.json`.
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
 
@@ -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 ora_1 = __importDefault(require("ora"));
10
+ const inquirer_1 = __importDefault(require("inquirer"));
11
11
  const path_1 = __importDefault(require("path"));
12
- const api_1 = require("../lib/api");
13
- const auth_1 = require("../lib/auth");
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
- exports.importCommand = new commander_1.Command('import')
59
- .description('Import commands from a JSON or shell script file')
60
- .argument('<file>', 'Path to the file to import')
61
- .option('-s, --skip-duplicates', 'Skip commands that already exist by name')
62
- .action(async (file, opts) => {
63
- const token = await (0, auth_1.requireAuth)();
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
- console.error(chalk_1.default.red(`\n Failed to parse file: ${err.message}\n`));
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'} to import\n`));
98
- let imported = 0;
99
- let skipped = 0;
100
- let failed = 0;
101
- for (const entry of entries) {
102
- const spinner = (0, ora_1.default)(`Importing: ${chalk_1.default.white(entry.name ?? entry.command.slice(0, 40))}`).start();
103
- try {
104
- await api_1.ApiClient.post('/commands', {
105
- command: entry.command,
106
- name: entry.name,
107
- tags: entry.tags,
108
- }, token);
109
- spinner.succeed(chalk_1.default.green(`Imported: ${entry.name ?? entry.command.slice(0, 40)}`));
110
- imported++;
111
- }
112
- catch (err) {
113
- if (opts.skipDuplicates && err.status === 409) {
114
- spinner.warn(chalk_1.default.yellow(`Skipped: ${entry.name ?? entry.command.slice(0, 40)}`));
115
- skipped++;
116
- }
117
- else {
118
- spinner.fail(chalk_1.default.red(`Failed: ${entry.name ?? entry.command.slice(0, 40)}`));
119
- failed++;
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(` ${chalk_1.default.green(`${imported} imported`)} ${chalk_1.default.yellow(`${skipped} skipped`)} ${chalk_1.default.red(`${failed} failed`)}`);
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
  });
@@ -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.9';
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+;(.*)$/);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devkitvault/recall",
3
- "version": "1.2.9",
3
+ "version": "1.2.10",
4
4
  "description": "recall CLI — personal command vault for your terminal (local-first)",
5
5
  "keywords": [
6
6
  "cli",