Skip to content

Commit 6009146

Browse files
committed
feat: add new commands snip last and snip import-history, enhance snip stats with streak tracking, and improve command descriptions and completions
1 parent e996d06 commit 6009146

File tree

19 files changed

+500
-99
lines changed

19 files changed

+500
-99
lines changed

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ snip doctor # validates storage, editor, fzf, shell, gist
101101
| `snip edit <name>` | Open in `$EDITOR` |
102102
| `snip rm <name>` | Delete (alias: `delete`) |
103103
| `snip update <name>` | Update metadata (`--tags`, `--lang`) |
104+
| `snip last` | Re-run the last executed snippet |
104105

105106
### Utilities
106107

@@ -110,7 +111,8 @@ snip doctor # validates storage, editor, fzf, shell, gist
110111
| `snip mv <old> <new>` | Rename a snippet |
111112
| `snip cat <name>` | Print raw content to stdout |
112113
| `snip recent [n]` | Show last _n_ used snippets (default: 5) |
113-
| `snip stats` | Library statistics (`--json`, language chart, top tags) |
114+
| `snip stats` | Library statistics (`--json`, language chart, top tags, `--streak`) |
115+
| `snip import-history` | Suggest commands from shell history (run 3+ times) |
114116
| `snip grab <url>` | Import from URL or `github:user/repo/path` |
115117
| `snip fzf` | fzf search with live preview |
116118

@@ -126,6 +128,7 @@ snip doctor # validates storage, editor, fzf, shell, gist
126128
| `snip doctor` | Health check |
127129
| `snip config <action>` | Get / set configuration |
128130
| `snip ui` | Interactive TUI |
131+
| `snip init` | Guided setup (editor, widget, example snippets, optional TUI) |
129132

130133
## Features
131134

__tests__/search.test.js

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@ const storage = require('../lib/storage');
22
const search = require('../lib/search');
33

44
describe('search', () => {
5-
test('finds snippet content', () => {
6-
const s = storage.addSnippet({ name: 'searchtest', content: 'unique-content-xyz123', language: 'txt', tags: ['u'] });
7-
const results = search.search('xyz123', 200);
8-
const ids = results.map(r => r.id);
9-
expect(ids).toContain(s.id);
5+
test('finds snippet by name and tags', () => {
6+
const s = storage.addSnippet({ name: 'searchtest-foo', content: 'echo hello', language: 'txt', tags: ['uniquetag'] });
7+
const byName = search.search('searchtest', 200);
8+
const byTag = search.search('uniquetag', 200);
9+
expect(byName.map(r => r.id)).toContain(s.id);
10+
expect(byTag.map(r => r.id)).toContain(s.id);
1011
});
1112
});

completions/snip.bash

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ _snip_completions() {
88
COMPREPLY=()
99
cur="${COMP_WORDS[COMP_CWORD]}"
1010
prev="${COMP_WORDS[COMP_CWORD-1]}"
11-
commands="add list search show run edit update rm delete export import sync ui config seed exec alias doctor cp mv cat recent fzf grab widget completion stats"
11+
commands="add list search show run edit update rm delete export import sync ui config seed exec alias doctor init last import-history cp mv cat recent fzf grab widget completion stats"
1212

1313
case "${prev}" in
1414
snip)

completions/snip.fish

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,16 @@ complete -c snip -n "__fish_use_subcommand" -a "import" -d "Import snippets"
2020
complete -c snip -n "__fish_use_subcommand" -a "sync" -d "Sync with GitHub Gists"
2121
complete -c snip -n "__fish_use_subcommand" -a "ui" -d "Interactive TUI"
2222
complete -c snip -n "__fish_use_subcommand" -a "config" -d "View/modify config"
23+
complete -c snip -n "__fish_use_subcommand" -a "exec" -d "Execute snippet immediately"
24+
complete -c snip -n "__fish_use_subcommand" -a "pipe" -d "Pipeline mode (stdin→template→stdout)"
25+
complete -c snip -n "__fish_use_subcommand" -a "alias" -d "Generate shell aliases"
26+
complete -c snip -n "__fish_use_subcommand" -a "doctor" -d "Health check"
27+
complete -c snip -n "__fish_use_subcommand" -a "stats" -d "Library statistics"
28+
complete -c snip -n "__fish_use_subcommand" -a "recent" -d "Recently used snippets"
29+
complete -c snip -n "__fish_use_subcommand" -a "last" -d "Re-run last executed snippet"
30+
complete -c snip -n "__fish_use_subcommand" -a "init" -d "Guided setup"
31+
complete -c snip -n "__fish_use_subcommand" -a "grab" -d "Import from URL or GitHub"
32+
complete -c snip -n "__fish_use_subcommand" -a "import-history" -d "Suggest commands from shell history"
2333

2434
# Options per subcommand
2535
complete -c snip -n "__fish_seen_subcommand_from add" -l lang -d "Language"
@@ -29,6 +39,11 @@ complete -c snip -n "__fish_seen_subcommand_from list" -l lang -d "Filter by lan
2939
complete -c snip -n "__fish_seen_subcommand_from list" -l sort -d "Sort order" -a "name usage recent"
3040
complete -c snip -n "__fish_seen_subcommand_from run" -l dry-run -d "Print without executing"
3141
complete -c snip -n "__fish_seen_subcommand_from run" -l confirm -d "Skip confirmation"
42+
complete -c snip -n "__fish_seen_subcommand_from stats" -l json -d "JSON output"
43+
complete -c snip -n "__fish_seen_subcommand_from stats" -l streak -d "Show usage streak"
44+
complete -c snip -n "__fish_seen_subcommand_from import-history" -l last -d "Analyze last N history lines"
45+
complete -c snip -n "__fish_seen_subcommand_from import-history" -l min-count -d "Minimum run count"
46+
complete -c snip -n "__fish_seen_subcommand_from import-history" -l json -d "JSON output"
3247
complete -c snip -n "__fish_seen_subcommand_from update" -l tags -d "Comma-separated tags"
3348
complete -c snip -n "__fish_seen_subcommand_from update" -l lang -d "Language"
3449
complete -c snip -n "__fish_seen_subcommand_from sync" -a "push pull"

docs/action_plan.md

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,8 @@
2525
**Outcome:** ~900 kB lighter, 4 fewer deps.
2626

2727
### Medium
28-
- [ ] **1.5** Add Fuse index cache invalidation — only rebuild when snippet count or DB mtime changes (in `lib/search.js`).
29-
- [ ] **1.6** Lazy-load snippet content in `storage.js` — metadata-only for `list`/`search`; load content only for `show`/`run`/`exec`.
28+
- [x] **1.5** Add Fuse index cache invalidation — only rebuild when snippet count or DB mtime changes (in `lib/search.js`).
29+
- [x] **1.6** Lazy-load snippet content in `storage.js` — metadata-only for `list`/`search`; load content only for `show`/`run`/`exec`.
3030

3131
### Hard
3232
- [ ] **1.7** Add in-process cache for `listSnippets()` so repeated commands in same process don’t reload full data every time.
@@ -42,10 +42,10 @@
4242
- [ ] **2.4** Centralize `process.exit()` — move exit logic into `cli.js` where possible; commands should throw or return status.
4343

4444
### Medium
45-
- [ ] **2.5** 🔴 Add **file lock** (or single-writer) for JSON backend in `storage.js` so two concurrent `snip add` don’t corrupt the DB (e.g. lock file or mutex around read-modify-write).
45+
- [x] **2.5** 🔴 Add **file lock** (or single-writer) for JSON backend in `storage.js` so two concurrent `snip add` don’t corrupt the DB (e.g. lock file or mutex around read-modify-write).
4646
- [x] **2.6** 🔴 Add `better-sqlite3` as **optional dependency** in `package.json` and document; add `snip doctor` check that suggests `npm install -g better-sqlite3` when SQLite is enabled but module missing.
4747
- [ ] **2.7** Normalize error handling — all commands: errors to stderr, non-zero exit on failure, consistent behavior for scripting.
48-
- [ ] **2.8** Enforce `--no-color` / `NO_COLOR` everywhere — audit chalk usage and respect flag/env in all commands.
48+
- [x] **2.8** Enforce `--no-color` / `NO_COLOR` everywhere — audit chalk usage and respect flag/env in all commands.
4949

5050
### Hard
5151
- [ ] **2.9** Raise coverage — bring command-layer coverage up (add tests for add, run, grab, pipe, show, stats, alias, sync; aim 60%+ on critical paths).
@@ -63,9 +63,9 @@
6363
- [x] **3.4** **Gist errors** — On 401 from GitHub API, show: "Invalid GitHub token. Set SNIP_GIST_TOKEN with a valid PAT."
6464

6565
### Medium
66-
- [ ] **3.5** **`snip run` vs `snip exec`** — Clarify in docs and help text; consider merging or one-line explanation in `--help` and README.
67-
- [ ] **3.6** **Pager for long lists** — When `snip list` returns 50+ snippets, use a pager (e.g. `less`) or suggest TUI.
68-
- [ ] **3.7** TUI first-run — Show a tiny guide or overlay on first `snip ui` (e.g. keybindings, "? for help").
66+
- [x] **3.5** **`snip run` vs `snip exec`** — Clarify in docs and help text; consider merging or one-line explanation in `--help` and README.
67+
- [x] **3.6** **Pager for long lists** — When `snip list` returns 50+ snippets, use a pager (e.g. `less`) or suggest TUI.
68+
- [x] **3.7** TUI first-run — Show a tiny guide or overlay on first `snip ui` (e.g. keybindings, "? for help").
6969

7070
### Hard
7171
- [ ] **3.8** Add inline help overlay in TUI (e.g. `?` key) with keybindings and confirm behavior.
@@ -82,10 +82,10 @@
8282
- [ ] **4.5** **`snip config`** — Add basic validation (type checking / allowed keys) instead of accepting any value.
8383

8484
### Medium
85-
- [ ] **4.6** **`snip init`** — Single guided wizard: choose editor → set up shell widget → seed example snippets → optional "open TUI". Target: zero to aha in ~60 seconds.
86-
- [ ] **4.7** **`snip last`** — Re-run last executed snippet (store last snippet id/name; simple persistence).
87-
- [ ] **4.8** **`snip stats --streak`** — Days in a row using snip (needs lightweight usage tracking).
88-
- [ ] **4.9** **CLI help** — One-line example in `--help` for each core command; align README examples with behavior.
85+
- [x] **4.6** **`snip init`** — Single guided wizard: choose editor → set up shell widget → seed example snippets → optional "open TUI". Target: zero to aha in ~60 seconds.
86+
- [x] **4.7** **`snip last`** — Re-run last executed snippet (store last snippet id/name; simple persistence).
87+
- [x] **4.8** **`snip stats --streak`** — Days in a row using snip (needs lightweight usage tracking).
88+
- [x] **4.9** **CLI help** — One-line example in `--help` for each core command; align README examples with behavior.
8989

9090
### Hard
9191
- [ ] **4.10** **`snip watch <name>`** — Re-run snippet on file edit (watch snippet file or DB change).
@@ -102,8 +102,8 @@
102102
- [x] **5.4** **`snip config` validation** — Reject invalid values and list allowed keys/types in help.
103103

104104
### Medium
105-
- [ ] **5.5** **Shell history import**`snip import-history --last 30`: analyze recent shell history, find commands run 3+ times, suggest saving as snippets.
106-
- [ ] **5.6** **Natural language search** — Make description first-class in search (e.g. higher weight in Fuse options) so "find my docker cleanup command" works well.
105+
- [x] **5.5** **Shell history import**`snip import-history --last 30`: analyze recent shell history, find commands run 3+ times, suggest saving as snippets.
106+
- [x] **5.6** **Natural language search** — Make description first-class in search (e.g. higher weight in Fuse options) so "find my docker cleanup command" works well.
107107

108108
### Hard
109109
- [ ] **5.7** **Context-aware suggestions** — In dir with `package.json` → suggest npm snippets; with `Dockerfile` → suggest docker snippets (needs context detection + tagging or categories).

lib/cli.js

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,38 +24,38 @@ program.hook('preAction', () => {
2424
program
2525
.command('add <name>')
2626
.description('Add a new snippet')
27-
.option('--lang <lang>')
28-
.option('--tags <tags>')
27+
.option('--lang <lang>', 'Language (sh, bash, python, etc.)')
28+
.option('--tags <tags>', 'Comma-separated tags')
2929
.action((name, opts) => addCmd(name, opts));
3030

3131
program
3232
.command('list')
3333
.description('List snippets')
34-
.option('-t, --tag <tag>')
35-
.option('--lang <lang>')
34+
.option('-t, --tag <tag>', 'Filter by tag')
35+
.option('--lang <lang>', 'Filter by language')
3636
.option('--sort <sort>', 'Sort by: name | usage | recent', 'name')
3737
.option('--limit <n>', 'Max items to show')
3838
.option('--json', 'Output as JSON')
3939
.action((opts) => listCmd(opts));
4040

4141
program
4242
.command('search <query>')
43-
.description('Fuzzy search snippets')
43+
.description('Fuzzy search by name and tags')
4444
.option('--limit <n>', 'Max results (default: 15)')
4545
.option('--json', 'Output as JSON')
4646
.action((q, opts) => searchCmd(q, opts));
4747

4848
program
4949
.command('show <idOrName>')
50-
.description('Show snippet content')
50+
.description('Show snippet content (use --raw to pipe)')
5151
.option('--edit', 'Open in editor')
5252
.option('--json', 'Output as JSON')
5353
.option('--raw', 'Print raw content (no header, for piping)')
5454
.action((idOrName, opts) => showCmd(idOrName, opts));
5555

5656
program
5757
.command('run <idOrName>')
58-
.description('Run a snippet (preview + confirm)')
58+
.description('Run a snippet with preview and confirm (use exec for no prompt)')
5959
.option('--dry-run', 'Print but do not execute')
6060
.option('--confirm', 'Skip confirmation prompt (danger check still runs)')
6161
.action((idOrName, opts) => runCmd(idOrName, opts));
@@ -114,7 +114,7 @@ program
114114
const execCmd2 = require('./commands/exec');
115115
program
116116
.command('exec <idOrName>')
117-
.description('Run a snippet immediately (no preview modal)')
117+
.description('Run snippet immediately without preview (run = preview+confirm, exec = run now)')
118118
.option('--dry-run', 'Print but do not execute')
119119
.option('--force', 'Skip dangerous-command warning')
120120
.action((idOrName, opts) => execCmd2(idOrName, opts));
@@ -140,6 +140,12 @@ program
140140
.description('Health check — verify storage, editor, fzf, gist sync')
141141
.action(() => doctorCmd());
142142

143+
const initCmd = require('./commands/init');
144+
program
145+
.command('init')
146+
.description('Guided setup: editor, widget, example snippets, optional TUI')
147+
.action(() => initCmd());
148+
143149
const fzfCmd = require('./commands/fzf');
144150
program
145151
.command('fzf')
@@ -192,8 +198,24 @@ program
192198
.command('stats')
193199
.description('Show snippet library statistics')
194200
.option('--json', 'Output as JSON')
201+
.option('--streak', 'Show days-in-a-row usage streak')
195202
.action((opts) => statsCmd(opts));
196203

204+
const lastCmd = require('./commands/last');
205+
program
206+
.command('last')
207+
.description('Re-run the last executed snippet')
208+
.action(() => lastCmd());
209+
210+
const importHistoryCmd = require('./commands/import-history');
211+
program
212+
.command('import-history')
213+
.description('Suggest commands from shell history run 3+ times')
214+
.option('--last <n>', 'Analyze last N history lines', '500')
215+
.option('--min-count <n>', 'Minimum run count to suggest', '3')
216+
.option('--json', 'Output as JSON')
217+
.action((opts) => importHistoryCmd(opts));
218+
197219
program
198220
.command('cp <source> <dest>')
199221
.description('Duplicate a snippet')

lib/colors.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
/**
22
* Centralized color helpers — graceful fallback when chalk unavailable or NO_COLOR set.
33
* Use this instead of defining chalk/colors in each command.
4+
* Respects process.env.NO_COLOR and --no-color (set by cli.js preAction).
45
*/
56
let chalk = null;
67
try {
78
const m = require('chalk');
89
chalk = (m && m.default) ? m.default : m;
10+
if (process.env.NO_COLOR) chalk = null;
911
} catch (_) {}
1012

1113
const c = {

lib/commands/exec.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,11 @@ async function execCmd(idOrName, opts = {}) {
5959
language: snippet.language
6060
});
6161

62-
if (status === 0) storage.touchUsage(snippet);
62+
if (status === 0) {
63+
storage.touchUsage(snippet);
64+
require('./last').setLastRun(snippet.id);
65+
require('../streak').recordUsage();
66+
}
6367
process.exitCode = status || 0;
6468
}
6569

lib/commands/import-history.js

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/**
2+
* snip import-history — analyze shell history and suggest commands run 3+ times.
3+
* Usage: snip import-history [--last N] [--min-count M]
4+
*/
5+
const fs = require('fs');
6+
const path = require('path');
7+
const os = require('os');
8+
const { c } = require('../colors');
9+
10+
const DEFAULT_LAST = 500;
11+
const MIN_COUNT = 3;
12+
const MIN_CMD_LEN = 4;
13+
14+
function getHistoryPath() {
15+
const histfile = process.env.HISTFILE;
16+
if (histfile && fs.existsSync(histfile)) return histfile;
17+
const shell = (process.env.SHELL || '').toLowerCase();
18+
const home = os.homedir();
19+
if (shell.includes('zsh')) return path.join(home, '.zsh_history');
20+
if (shell.includes('bash')) return path.join(home, '.bash_history');
21+
return path.join(home, '.zsh_history');
22+
}
23+
24+
function parseZshLine(line) {
25+
const m = line.match(/^: \d+:\d+;(.*)/);
26+
return m ? m[1].trim() : line.trim();
27+
}
28+
29+
function parseBashLine(line) {
30+
return line.trim();
31+
}
32+
33+
function importHistoryCmd(opts = {}) {
34+
const last = Math.min(Math.max(1, parseInt(opts.last) || DEFAULT_LAST), 10000);
35+
const minCount = Math.max(2, parseInt(opts.minCount) || MIN_COUNT);
36+
const histPath = getHistoryPath();
37+
38+
if (!fs.existsSync(histPath)) {
39+
console.error(`History file not found: ${histPath}`);
40+
console.error('Set HISTFILE or use bash/zsh with default history path.');
41+
process.exitCode = 1;
42+
return;
43+
}
44+
45+
const raw = fs.readFileSync(histPath, 'utf8');
46+
const isZsh = histPath.includes('zsh_history');
47+
const lines = raw.split('\n').slice(-last).map(l => isZsh ? parseZshLine(l) : parseBashLine(l));
48+
const countByCmd = {};
49+
for (const line of lines) {
50+
const cmd = line.trim();
51+
if (cmd.length < MIN_CMD_LEN) continue;
52+
countByCmd[cmd] = (countByCmd[cmd] || 0) + 1;
53+
}
54+
const suggested = Object.entries(countByCmd)
55+
.filter(([, n]) => n >= minCount)
56+
.sort((a, b) => b[1] - a[1])
57+
.slice(0, 30);
58+
59+
if (opts.json) {
60+
console.log(JSON.stringify(suggested.map(([cmd, n]) => ({ command: cmd, count: n })), null, 2));
61+
return;
62+
}
63+
64+
if (suggested.length === 0) {
65+
console.log(c.muted(`No commands run ${minCount}+ times in the last ${last} history lines.`));
66+
console.log(c.dim(' Try --last 1000 or --min-count 2'));
67+
return;
68+
}
69+
70+
console.log(c.accent(`\n Commands run ${minCount}+ times (from last ${last} history lines):\n`));
71+
suggested.forEach(([cmd, n], i) => {
72+
const preview = cmd.length > 60 ? cmd.slice(0, 57) + '…' : cmd;
73+
console.log(` ${(i + 1).toString().padStart(2)}. ${c.val(n + '×')} ${c.dim(preview)}`);
74+
});
75+
console.log(c.dim('\n Add one: snip add <name> (then paste the command, or use snip grab)'));
76+
console.log('');
77+
}
78+
79+
module.exports = importHistoryCmd;

0 commit comments

Comments
 (0)