shell-aliasforge 0.0.0-stage → 0.1.1
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 +293 -2
- package/dist/analyze/environment.js +84 -0
- package/dist/analyze/naming.js +84 -0
- package/dist/analyze/normalize.js +160 -0
- package/dist/analyze/render.js +23 -0
- package/dist/analyze/report.js +27 -0
- package/dist/analyze/reserved.js +33 -0
- package/dist/analyze/score.js +4 -0
- package/dist/analyze/stats.js +128 -0
- package/dist/analyze/suggest.js +68 -0
- package/dist/analyze/templates.js +90 -0
- package/dist/apply/apply.js +68 -0
- package/dist/apply/backup.js +20 -0
- package/dist/apply/block.js +98 -0
- package/dist/apply/diff.js +41 -0
- package/dist/apply/select.js +23 -0
- package/dist/cli.js +229 -0
- package/dist/errors.js +3 -0
- package/dist/history/bash.js +22 -0
- package/dist/history/detect.js +36 -0
- package/dist/history/fish.js +29 -0
- package/dist/history/index.js +24 -0
- package/dist/history/text.js +13 -0
- package/dist/history/types.js +1 -0
- package/dist/history/zsh.js +62 -0
- package/dist/risks/engine.js +74 -0
- package/dist/risks/mask.js +23 -0
- package/dist/risks/packs/cloud.js +60 -0
- package/dist/risks/packs/core.js +177 -0
- package/dist/risks/packs/docker.js +27 -0
- package/dist/risks/packs/git.js +27 -0
- package/dist/risks/packs/index.js +18 -0
- package/dist/risks/packs/kubectl.js +35 -0
- package/dist/risks/packs/systemd.js +19 -0
- package/dist/risks/report.js +30 -0
- package/dist/risks/types.js +1 -0
- package/package.json +49 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pratik Surve
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,294 @@
|
|
|
1
|
-
#
|
|
1
|
+
# AliasForge
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Reads your shell history **locally** and suggests aliases and functions for the commands you repeat most. It also flags risky habits (`chmod -R 777`, `rm -rf $VAR`) and secrets typed into commands.
|
|
4
|
+
|
|
5
|
+
Offline by design. No network calls, no telemetry, and it never runs a command from your history.
|
|
6
|
+
|
|
7
|
+
> Status: pre-release (not on npm yet). `suggest`, `risks`, `apply` and `stats` work.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
The npm package is named `shell-aliasforge`; the command it installs is `aliasforge`. It is not published yet, so for now build from a clone (Node 24 or newer):
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
git clone https://github.com/prsurve/aliasforge.git
|
|
15
|
+
cd aliasforge
|
|
16
|
+
npm ci
|
|
17
|
+
npm run build
|
|
18
|
+
node dist/cli.js --help
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
After the first release:
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
npm install -g shell-aliasforge
|
|
25
|
+
aliasforge suggest
|
|
26
|
+
|
|
27
|
+
# or without installing
|
|
28
|
+
npx shell-aliasforge suggest
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
The examples below use the `aliasforge` command.
|
|
32
|
+
|
|
33
|
+
## Commands
|
|
34
|
+
|
|
35
|
+
### `aliasforge suggest`
|
|
36
|
+
|
|
37
|
+
Ranks the aliases that would save the most keystrokes, with ready-to-paste definitions.
|
|
38
|
+
|
|
39
|
+
```sh
|
|
40
|
+
aliasforge suggest [--shell bash|zsh|fish] [--file <path>] [--min-count N] [--min-length N] [--top N] [--json]
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Example, on a synthetic history file (`test/fixtures/suggest_history`):
|
|
44
|
+
|
|
45
|
+
```text
|
|
46
|
+
$ aliasforge suggest --file test/fixtures/suggest_history --shell zsh
|
|
47
|
+
# Score Count Pattern Names
|
|
48
|
+
1 114 6 kubectl get pods -n prod kgpnp, kgpp, kugpp
|
|
49
|
+
2 108 12 git checkout <arg> gco, gc, gic
|
|
50
|
+
3 102 6 docker exec -it <arg> bash (function) deib, deb, doeb
|
|
51
|
+
4 96 12 git status gs, gst, gis
|
|
52
|
+
|
|
53
|
+
Copy-paste for zsh:
|
|
54
|
+
|
|
55
|
+
# aliasforge suggestions
|
|
56
|
+
alias kgpnp='kubectl get pods -n prod'
|
|
57
|
+
alias gco='git checkout'
|
|
58
|
+
deib() { docker exec -it "$1" bash; }
|
|
59
|
+
alias gs='git status'
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
How it works:
|
|
63
|
+
|
|
64
|
+
- Commands are normalized (whitespace collapsed, trailing `\` removed) and also split on `&&`, `||`, `|` and `;`, so pieces of pipelines count too.
|
|
65
|
+
- Identical commands group together. Commands that differ only in one argument group into a template: `git checkout <arg>`.
|
|
66
|
+
- **Score** = occurrences x (typed length - alias name length). Defaults: at least 5 uses (`--min-count`) and 8 characters (`--min-length`).
|
|
67
|
+
- A template whose last argument varies becomes an **alias** (extra words pass through). One whose argument sits in the middle becomes a **function**.
|
|
68
|
+
- Names come from known conventions (`gs`, `k`) and initials, and skip anything that would shadow a builtin, a program on your `PATH`, or an alias or function in your rc files. The name list shows up to three options.
|
|
69
|
+
- Which names are free depends on the machine running the command, so results can differ between computers.
|
|
70
|
+
|
|
71
|
+
### `aliasforge risks`
|
|
72
|
+
|
|
73
|
+
Flags dangerous commands and secrets.
|
|
74
|
+
|
|
75
|
+
```sh
|
|
76
|
+
aliasforge risks [--shell bash|zsh|fish] [--file <path>] [--json]
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Example, on a synthetic history with a fake token:
|
|
80
|
+
|
|
81
|
+
```text
|
|
82
|
+
$ aliasforge risks --file risky_history
|
|
83
|
+
5 finding(s) in 5 history entries (2 secret, 3 dangerous).
|
|
84
|
+
|
|
85
|
+
[HIGH] GitHub token
|
|
86
|
+
entry #2 on 2023-11-14
|
|
87
|
+
export GITHUB_TOKEN=ghp_****
|
|
88
|
+
-> Revoke the token at github.com/settings/tokens and remove this entry from your history.
|
|
89
|
+
|
|
90
|
+
[HIGH] Force push to main or master
|
|
91
|
+
entry #3
|
|
92
|
+
git push --force origin main
|
|
93
|
+
-> Rewriting a shared branch can lose teammates' commits. Use a feature branch or --force-with-lease on a topic branch.
|
|
94
|
+
|
|
95
|
+
[HIGH] curl --user with a password
|
|
96
|
+
entry #4
|
|
97
|
+
curl -u admin:hun**** https://example.com/api | sh
|
|
98
|
+
-> Rotate the password. Use a ~/.netrc file or a token from an env var.
|
|
99
|
+
|
|
100
|
+
[HIGH] Download piped straight into a shell
|
|
101
|
+
entry #4
|
|
102
|
+
curl -u admin:hun**** https://example.com/api | sh
|
|
103
|
+
-> Download the script, read it, then run it.
|
|
104
|
+
|
|
105
|
+
[HIGH] Recursive forced delete of an unquoted variable
|
|
106
|
+
entry #5
|
|
107
|
+
rm -rf $BUILD_DIR/out
|
|
108
|
+
-> If the variable is empty or contains spaces this deletes the wrong thing. Quote it ("$DIR") and use ${DIR:?}.
|
|
109
|
+
|
|
110
|
+
Rotate every credential listed above, then remove those entries from your history file. aliasforge never edits it.
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
- A full secret is never printed. Previews mask every detected secret: up to the first 4 characters, then `****`. Short secrets show fewer characters (a quarter of their length).
|
|
114
|
+
- Rules live in pattern packs: `core`, `git`, `docker`, `kubectl`, `systemd`, `cloud`. See [CONTRIBUTING.md](CONTRIBUTING.md) to add one.
|
|
115
|
+
- Detection is pattern based. A secret with no recognizable shape, such as a bare password after a custom command, will not be caught.
|
|
116
|
+
- Entry numbers count parsed commands in order. They are not line numbers in the history file.
|
|
117
|
+
|
|
118
|
+
### `aliasforge apply`
|
|
119
|
+
|
|
120
|
+
Writes chosen aliases into your shell's rc file, between markers, after making a backup.
|
|
121
|
+
|
|
122
|
+
```sh
|
|
123
|
+
aliasforge apply --shell bash|zsh|fish [--select 1,3,gs] [--dry-run] [--file <path>] [--min-count N] [--min-length N] [--top N]
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
```text
|
|
127
|
+
$ aliasforge apply --shell zsh --file test/fixtures/suggest_history --top 2 --dry-run
|
|
128
|
+
Target: /home/you/.zshrc
|
|
129
|
+
Added: kgpnp, gco
|
|
130
|
+
|
|
131
|
+
--- /home/you/.zshrc
|
|
132
|
+
+++ /home/you/.zshrc
|
|
133
|
+
@@ -1,1 +1,7 @@
|
|
134
|
+
export EDITOR=vim
|
|
135
|
+
+
|
|
136
|
+
+# >>> aliasforge >>>
|
|
137
|
+
+# Managed by aliasforge. `aliasforge apply` edits only this block.
|
|
138
|
+
+alias kgpnp='kubectl get pods -n prod'
|
|
139
|
+
+alias gco='git checkout'
|
|
140
|
+
+# <<< aliasforge <<<
|
|
141
|
+
|
|
142
|
+
Dry run: nothing was written.
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
- `--shell` is required because it decides which file is edited: `~/.bashrc`, `${ZDOTDIR:-~}/.zshrc` or `~/.config/fish/config.fish`.
|
|
146
|
+
- `--select` takes ranks from `suggest` and/or proposed names. Leave it out to apply every suggestion. The `--top`, `--min-count` and `--min-length` values should match the `suggest` run you looked at, since ranks depend on them.
|
|
147
|
+
- Only the text between `# >>> aliasforge >>>` and `# <<< aliasforge <<<` is touched.
|
|
148
|
+
- Before any change the file is copied to `<rc>.aliasforge-backup-<UTC timestamp>`. Backups are never overwritten.
|
|
149
|
+
- Definitions already in the block are kept, so applying again adds to it. If a name clashes with a different definition in the block, the next proposed name is used, or the suggestion is skipped.
|
|
150
|
+
- Running it twice changes nothing: if the result would match the file, nothing is written and no backup is made.
|
|
151
|
+
- A damaged block (missing, duplicate or reversed markers) stops the command and changes nothing.
|
|
152
|
+
- Remove the block by hand to uninstall.
|
|
153
|
+
|
|
154
|
+
### `aliasforge stats`
|
|
155
|
+
|
|
156
|
+
History size, most-used programs and the time span.
|
|
157
|
+
|
|
158
|
+
```sh
|
|
159
|
+
aliasforge stats [--shell bash|zsh|fish] [--file <path>] [--top N] [--json]
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
```text
|
|
163
|
+
$ aliasforge stats --file history.txt --top 3
|
|
164
|
+
Shell: bash
|
|
165
|
+
Entries: 4 (4 unique)
|
|
166
|
+
Time span: 2023-11-14 to 2024-01-11 (57 days, 3 entries with a time)
|
|
167
|
+
|
|
168
|
+
Top commands:
|
|
169
|
+
1 git 2
|
|
170
|
+
2 apt 1
|
|
171
|
+
3 grep 1
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
- Programs are counted per sub-command, so both sides of a pipeline count. `VAR=x`, `sudo`, `time` and similar prefixes are skipped to find the real program.
|
|
175
|
+
- The time span needs timestamps. Plain bash history has none unless `HISTTIMEFORMAT` was set.
|
|
176
|
+
- Ties are listed alphabetically.
|
|
177
|
+
|
|
178
|
+
## `--json` output
|
|
179
|
+
|
|
180
|
+
All JSON is printed to stdout. New fields may be added in later versions; existing ones keep their meaning.
|
|
181
|
+
|
|
182
|
+
### `suggest --json`
|
|
183
|
+
|
|
184
|
+
```json
|
|
185
|
+
{
|
|
186
|
+
"shell": "zsh",
|
|
187
|
+
"historyEntries": 39,
|
|
188
|
+
"suggestions": [
|
|
189
|
+
{
|
|
190
|
+
"rank": 2,
|
|
191
|
+
"kind": "alias",
|
|
192
|
+
"pattern": "git checkout <arg>",
|
|
193
|
+
"command": "git checkout",
|
|
194
|
+
"count": 12,
|
|
195
|
+
"score": 108,
|
|
196
|
+
"names": ["gco", "gc", "gic"],
|
|
197
|
+
"definition": "alias gco='git checkout'"
|
|
198
|
+
}
|
|
199
|
+
]
|
|
200
|
+
}
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
| Field | Type | Meaning |
|
|
204
|
+
| --- | --- | --- |
|
|
205
|
+
| `shell` | `"bash" \| "zsh" \| "fish"` | Dialect used for `definition`, and how the history was parsed |
|
|
206
|
+
| `historyEntries` | number | Commands parsed from the history file |
|
|
207
|
+
| `suggestions[].rank` | number | 1-based position, best first |
|
|
208
|
+
| `suggestions[].kind` | `"alias" \| "function"` | What `definition` defines |
|
|
209
|
+
| `suggestions[].pattern` | string | What is being replaced. `<arg>` marks the part that varies |
|
|
210
|
+
| `suggestions[].command` | string | The alias body. For a function, the body with `<arg>` where the argument goes |
|
|
211
|
+
| `suggestions[].count` | number | How often the pattern occurs |
|
|
212
|
+
| `suggestions[].score` | number | `count x (typed length - name length)`, using the first name |
|
|
213
|
+
| `suggestions[].names` | string[] | Up to three free names. The first is used in `definition` |
|
|
214
|
+
| `suggestions[].definition` | string | Ready to paste. May contain newlines (fish functions) |
|
|
215
|
+
|
|
216
|
+
### `risks --json`
|
|
217
|
+
|
|
218
|
+
```json
|
|
219
|
+
{
|
|
220
|
+
"shell": "bash",
|
|
221
|
+
"historyEntries": 5,
|
|
222
|
+
"findings": [
|
|
223
|
+
{
|
|
224
|
+
"id": "git/github-token",
|
|
225
|
+
"category": "secret",
|
|
226
|
+
"severity": "high",
|
|
227
|
+
"title": "GitHub token",
|
|
228
|
+
"advice": "Revoke the token at github.com/settings/tokens and remove this entry from your history.",
|
|
229
|
+
"entry": 2,
|
|
230
|
+
"preview": "export GITHUB_TOKEN=ghp_****",
|
|
231
|
+
"date": "2023-11-14"
|
|
232
|
+
}
|
|
233
|
+
]
|
|
234
|
+
}
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
| Field | Type | Meaning |
|
|
238
|
+
| --- | --- | --- |
|
|
239
|
+
| `shell`, `historyEntries` | | As above |
|
|
240
|
+
| `findings[].id` | string | Rule id, `<pack>/<name>` |
|
|
241
|
+
| `findings[].category` | `"danger" \| "secret"` | |
|
|
242
|
+
| `findings[].severity` | `"high" \| "medium" \| "low"` | Findings are sorted high first, then by `entry` |
|
|
243
|
+
| `findings[].title`, `advice` | string | Human-readable |
|
|
244
|
+
| `findings[].entry` | number | 1-based position of the command in the parsed history |
|
|
245
|
+
| `findings[].preview` | string | The command with secrets masked, cut to 100 characters |
|
|
246
|
+
| `findings[].date` | string, optional | `YYYY-MM-DD` (UTC), present only if the history file recorded a time |
|
|
247
|
+
|
|
248
|
+
### `stats --json`
|
|
249
|
+
|
|
250
|
+
```json
|
|
251
|
+
{
|
|
252
|
+
"shell": "bash",
|
|
253
|
+
"historyEntries": 4,
|
|
254
|
+
"uniqueCommands": 4,
|
|
255
|
+
"timestampedEntries": 3,
|
|
256
|
+
"firstDate": "2023-11-14",
|
|
257
|
+
"lastDate": "2024-01-11",
|
|
258
|
+
"spanDays": 57,
|
|
259
|
+
"topCommands": [{ "command": "git", "count": 2 }]
|
|
260
|
+
}
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
`firstDate`, `lastDate` and `spanDays` are left out when the history has no timestamps. `uniqueCommands` counts distinct commands after whitespace is collapsed.
|
|
264
|
+
|
|
265
|
+
## History files
|
|
266
|
+
|
|
267
|
+
By default AliasForge reads `~/.bash_history`, `~/.zsh_history` or `~/.local/share/fish/fish_history`, based on `--shell` or `$SHELL`. Point it at any file with `--file`; the shell is guessed from the file name and content unless you pass `--shell`.
|
|
268
|
+
|
|
269
|
+
- **bash:** one command per line. `#1700000000` timestamp lines (from `HISTTIMEFORMAT`) are used for dates.
|
|
270
|
+
- **zsh:** plain or extended (`: 1700000000:0;command`) lines, backslash continuations, and zsh's escaped ("metafied") bytes.
|
|
271
|
+
- **fish:** `- cmd:` / `when:` entries.
|
|
272
|
+
- Malformed lines, CRLF, a BOM, blank lines and very large files are handled.
|
|
273
|
+
- The working directory is not recorded in history, so there are no per-directory suggestions.
|
|
274
|
+
|
|
275
|
+
## Privacy and safety
|
|
276
|
+
|
|
277
|
+
- No network access. A test fails if the source imports `fetch`, `http` or similar.
|
|
278
|
+
- Commands from history are only read as text. They are never executed.
|
|
279
|
+
- `suggest` and `risks` are read-only. `apply` is the only command that writes, and only inside its marked block, after a backup.
|
|
280
|
+
- Alias bodies are single-quoted when written. A function is offered only if its body has balanced quotes and brackets and nothing that could break out of it, because that text is pasted into your rc file.
|
|
281
|
+
- Collision checks read the directories on your `PATH` and your rc files. They never start a shell.
|
|
282
|
+
|
|
283
|
+
## Not the same as
|
|
284
|
+
|
|
285
|
+
- Alias managers (store aliases you write yourself): AliasForge *discovers* them from what you actually type.
|
|
286
|
+
- The unrelated OSINT repo also named "Aliasforge": no connection.
|
|
287
|
+
|
|
288
|
+
## Contributing
|
|
289
|
+
|
|
290
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md). The full spec and build plan is in [CLAUDE.md](CLAUDE.md).
|
|
291
|
+
|
|
292
|
+
## License
|
|
293
|
+
|
|
294
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { accessSync, constants, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { delimiter, join } from "node:path";
|
|
3
|
+
import { stripManagedBlock } from "../apply/block.js";
|
|
4
|
+
import { isReserved } from "./reserved.js";
|
|
5
|
+
const RC_FILES = {
|
|
6
|
+
bash: [".bashrc", ".bash_profile", ".bash_aliases", ".profile"],
|
|
7
|
+
zsh: [".zshrc", ".zprofile", ".zshenv"],
|
|
8
|
+
fish: [".config/fish/config.fish"],
|
|
9
|
+
};
|
|
10
|
+
const ALIAS_LINE = /^\s*(?:alias|abbr)\s+(?:-\S+\s+)*([^\s=]+)/;
|
|
11
|
+
const FUNCTION_KEYWORD = /^\s*function\s+([^\s({;]+)/;
|
|
12
|
+
const FUNCTION_PARENS = /^\s*([A-Za-z_][\w.-]*)\s*\(\s*\)/;
|
|
13
|
+
function readText(path) {
|
|
14
|
+
try {
|
|
15
|
+
return readFileSync(path, "utf8");
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return "";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function listDir(path) {
|
|
22
|
+
try {
|
|
23
|
+
return readdirSync(path);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function isExecutable(path) {
|
|
30
|
+
try {
|
|
31
|
+
accessSync(path, constants.X_OK);
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function namesOnPath(pathVar) {
|
|
39
|
+
const names = new Set();
|
|
40
|
+
for (const dir of pathVar.split(delimiter).filter((entry) => entry !== "")) {
|
|
41
|
+
for (const file of listDir(dir)) {
|
|
42
|
+
if (!names.has(file) && isExecutable(join(dir, file)))
|
|
43
|
+
names.add(file);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return names;
|
|
47
|
+
}
|
|
48
|
+
/** Names defined by alias/function/abbr lines in the given rc text. */
|
|
49
|
+
export function definedNames(rcText) {
|
|
50
|
+
const names = new Set();
|
|
51
|
+
for (const line of rcText.split(/\r?\n/)) {
|
|
52
|
+
const match = ALIAS_LINE.exec(line) ?? FUNCTION_KEYWORD.exec(line) ?? FUNCTION_PARENS.exec(line);
|
|
53
|
+
if (match)
|
|
54
|
+
names.add(match[1]);
|
|
55
|
+
}
|
|
56
|
+
return names;
|
|
57
|
+
}
|
|
58
|
+
function fishFunctionFiles(home) {
|
|
59
|
+
const dirs = [join(home, ".config/fish/functions"), join(home, ".config/fish/conf.d")];
|
|
60
|
+
return dirs.flatMap((dir) => listDir(dir).map((file) => join(dir, file)));
|
|
61
|
+
}
|
|
62
|
+
function namesFromRcFiles(shell, home) {
|
|
63
|
+
const files = RC_FILES[shell].map((file) => join(home, file));
|
|
64
|
+
if (shell === "fish")
|
|
65
|
+
files.push(...fishFunctionFiles(home));
|
|
66
|
+
const names = new Set();
|
|
67
|
+
for (const file of files) {
|
|
68
|
+
// Names we wrote ourselves are not collisions, or `apply` would never be idempotent.
|
|
69
|
+
for (const name of definedNames(stripManagedBlock(readText(file))))
|
|
70
|
+
names.add(name);
|
|
71
|
+
if (shell === "fish" && file.endsWith(".fish"))
|
|
72
|
+
names.add(file.slice(file.lastIndexOf("/") + 1, -5));
|
|
73
|
+
}
|
|
74
|
+
return names;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Checks whether a proposed alias name would shadow something. It reads PATH
|
|
78
|
+
* directories and rc files; it never runs a shell or any command.
|
|
79
|
+
*/
|
|
80
|
+
export function createEnvironment(options) {
|
|
81
|
+
const onPath = namesOnPath(options.path);
|
|
82
|
+
const defined = namesFromRcFiles(options.shell, options.home);
|
|
83
|
+
return (name) => isReserved(options.shell, name) || onPath.has(name) || defined.has(name);
|
|
84
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { isFlag, tokenize } from "./normalize.js";
|
|
2
|
+
import { kindOf, VAR } from "./templates.js";
|
|
3
|
+
const MAX_NAME_LENGTH = 5;
|
|
4
|
+
const MAX_OPTIONS = 3;
|
|
5
|
+
const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
6
|
+
const ALIAS_NAME = /^[A-Za-z0-9_.-]+$/;
|
|
7
|
+
/** Well-known short names, keyed by the exact command they stand for. */
|
|
8
|
+
const CONVENTIONS = {
|
|
9
|
+
git: ["g"],
|
|
10
|
+
kubectl: ["k"],
|
|
11
|
+
docker: ["d"],
|
|
12
|
+
"git status": ["gs", "gst"],
|
|
13
|
+
"git add": ["ga"],
|
|
14
|
+
"git add .": ["gaa", "gad"],
|
|
15
|
+
"git commit": ["gc", "gci"],
|
|
16
|
+
"git commit -m": ["gcm"],
|
|
17
|
+
"git checkout": ["gco"],
|
|
18
|
+
"git diff": ["gd"],
|
|
19
|
+
"git pull": ["gpl", "gl"],
|
|
20
|
+
"git push": ["gp", "gps"],
|
|
21
|
+
"git log": ["glg", "gl"],
|
|
22
|
+
"git log --oneline": ["glo"],
|
|
23
|
+
"docker ps": ["dps"],
|
|
24
|
+
"docker compose up": ["dcu"],
|
|
25
|
+
"docker compose down": ["dcd"],
|
|
26
|
+
"kubectl get pods": ["kgp"],
|
|
27
|
+
"ls -la": ["ll", "la"],
|
|
28
|
+
"cd ..": ["..", "up"],
|
|
29
|
+
"npm test": ["nt"],
|
|
30
|
+
"npm install": ["ni"],
|
|
31
|
+
"npm run dev": ["nrd"],
|
|
32
|
+
"npm run build": ["nrb"],
|
|
33
|
+
};
|
|
34
|
+
function firstAlnum(word) {
|
|
35
|
+
return /[A-Za-z0-9]/.exec(word)?.[0].toLowerCase() ?? "";
|
|
36
|
+
}
|
|
37
|
+
function programPrefix(program, length) {
|
|
38
|
+
const base = program.slice(program.lastIndexOf("/") + 1).replace(/[^A-Za-z0-9]/g, "");
|
|
39
|
+
return base.slice(0, length).toLowerCase();
|
|
40
|
+
}
|
|
41
|
+
function initials(words, options) {
|
|
42
|
+
const [program = "", ...rest] = words;
|
|
43
|
+
const letters = rest
|
|
44
|
+
.filter((word) => options.flags || !isFlag(word))
|
|
45
|
+
.map(firstAlnum)
|
|
46
|
+
.join("");
|
|
47
|
+
return (programPrefix(program, options.programChars) + letters).slice(0, MAX_NAME_LENGTH);
|
|
48
|
+
}
|
|
49
|
+
function validFor(candidate, name) {
|
|
50
|
+
const pattern = kindOf(candidate) === "function" ? IDENTIFIER : ALIAS_NAME;
|
|
51
|
+
return pattern.test(name) && name.length < candidate.replacedLength;
|
|
52
|
+
}
|
|
53
|
+
function rawOptions(candidate, words) {
|
|
54
|
+
const known = candidate.shape === "middle" ? [] : (CONVENTIONS[words.join(" ")] ?? []);
|
|
55
|
+
return [
|
|
56
|
+
...known,
|
|
57
|
+
initials(words, { flags: true, programChars: 1 }),
|
|
58
|
+
initials(words, { flags: false, programChars: 1 }),
|
|
59
|
+
initials(words, { flags: false, programChars: 2 }),
|
|
60
|
+
];
|
|
61
|
+
}
|
|
62
|
+
function withSuffix(base, isTaken) {
|
|
63
|
+
for (let n = 2; n <= 9; n++) {
|
|
64
|
+
if (!isTaken(`${base}${n}`))
|
|
65
|
+
return `${base}${n}`;
|
|
66
|
+
}
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Propose up to three names for a candidate: known conventions first, then
|
|
71
|
+
* initials. Names that collide with commands, builtins or existing aliases
|
|
72
|
+
* and functions are skipped. Returns an empty list when nothing is free.
|
|
73
|
+
*/
|
|
74
|
+
export function proposeNames(candidate, isTaken) {
|
|
75
|
+
const words = tokenize(candidate.command).filter((word) => word !== VAR);
|
|
76
|
+
const options = rawOptions(candidate, words).filter((name) => validFor(candidate, name));
|
|
77
|
+
const free = [...new Set(options)].filter((name) => !isTaken(name));
|
|
78
|
+
if (free.length === 0 && options[0] !== undefined) {
|
|
79
|
+
const suffixed = withSuffix(options[0], isTaken);
|
|
80
|
+
if (suffixed !== undefined && validFor(candidate, suffixed))
|
|
81
|
+
free.push(suffixed);
|
|
82
|
+
}
|
|
83
|
+
return free.slice(0, MAX_OPTIONS);
|
|
84
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/** Trim, collapse whitespace (newlines included) and strip a trailing backslash. */
|
|
2
|
+
export function normalizeCommand(raw) {
|
|
3
|
+
return raw.replace(/\s+/g, " ").trim().replace(/(?:\s*\\)+$/, "").trim();
|
|
4
|
+
}
|
|
5
|
+
export function isFlag(token) {
|
|
6
|
+
return token.startsWith("-") && token.length > 1;
|
|
7
|
+
}
|
|
8
|
+
/** Split text on delimiters that sit outside quotes. Empty parts are dropped. */
|
|
9
|
+
function splitUnquoted(text, delimiterAt) {
|
|
10
|
+
const parts = [];
|
|
11
|
+
let current = "";
|
|
12
|
+
let quote = "";
|
|
13
|
+
let i = 0;
|
|
14
|
+
while (i < text.length) {
|
|
15
|
+
const char = text[i];
|
|
16
|
+
const escaped = char === "\\" && quote !== "'" && i + 1 < text.length;
|
|
17
|
+
if (escaped) {
|
|
18
|
+
current += char + text[i + 1];
|
|
19
|
+
i += 2;
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
if (quote) {
|
|
23
|
+
if (char === quote)
|
|
24
|
+
quote = "";
|
|
25
|
+
}
|
|
26
|
+
else if (char === "'" || char === '"') {
|
|
27
|
+
quote = char;
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
const length = delimiterAt(text, i);
|
|
31
|
+
if (length > 0) {
|
|
32
|
+
parts.push(current);
|
|
33
|
+
current = "";
|
|
34
|
+
i += length;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
current += char;
|
|
39
|
+
i++;
|
|
40
|
+
}
|
|
41
|
+
parts.push(current);
|
|
42
|
+
return parts.map((part) => part.trim()).filter((part) => part !== "");
|
|
43
|
+
}
|
|
44
|
+
function operatorAt(text, index) {
|
|
45
|
+
const pair = text.slice(index, index + 2);
|
|
46
|
+
if (pair === "&&" || pair === "||")
|
|
47
|
+
return 2;
|
|
48
|
+
return text[index] === "|" || text[index] === ";" ? 1 : 0;
|
|
49
|
+
}
|
|
50
|
+
function whitespaceAt(text, index) {
|
|
51
|
+
return /\s/.test(text[index]) ? 1 : 0;
|
|
52
|
+
}
|
|
53
|
+
/** Split a command line on `&&`, `||`, `|` and `;` outside quotes. */
|
|
54
|
+
export function splitSubcommands(command) {
|
|
55
|
+
return splitUnquoted(command, operatorAt);
|
|
56
|
+
}
|
|
57
|
+
/** Split into words on whitespace outside quotes. Quotes stay in the tokens. */
|
|
58
|
+
export function tokenize(command) {
|
|
59
|
+
return splitUnquoted(command, whitespaceAt);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* True when quotes, parentheses, braces and backticks outside quotes pair up.
|
|
63
|
+
* Used to keep text that would break a shell function body out of rc files.
|
|
64
|
+
*/
|
|
65
|
+
export function isBalanced(command) {
|
|
66
|
+
let quote = "";
|
|
67
|
+
let parens = 0;
|
|
68
|
+
let braces = 0;
|
|
69
|
+
let backticks = 0;
|
|
70
|
+
for (let i = 0; i < command.length; i++) {
|
|
71
|
+
const char = command[i];
|
|
72
|
+
if (char === "\\" && quote !== "'") {
|
|
73
|
+
if (i + 1 >= command.length)
|
|
74
|
+
return false;
|
|
75
|
+
i++;
|
|
76
|
+
}
|
|
77
|
+
else if (quote) {
|
|
78
|
+
if (char === quote)
|
|
79
|
+
quote = "";
|
|
80
|
+
}
|
|
81
|
+
else if (char === "'" || char === '"') {
|
|
82
|
+
quote = char;
|
|
83
|
+
}
|
|
84
|
+
else if (char === "`") {
|
|
85
|
+
backticks++;
|
|
86
|
+
}
|
|
87
|
+
else if (char === "(" || char === ")") {
|
|
88
|
+
parens += char === "(" ? 1 : -1;
|
|
89
|
+
}
|
|
90
|
+
else if (char === "{" || char === "}") {
|
|
91
|
+
braces += char === "{" ? 1 : -1;
|
|
92
|
+
}
|
|
93
|
+
if (parens < 0 || braces < 0)
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
return quote === "" && parens === 0 && braces === 0 && backticks % 2 === 0;
|
|
97
|
+
}
|
|
98
|
+
function isLineBreak(raw, index) {
|
|
99
|
+
return raw[index] === "\n" || (raw[index] === "\r" && raw[index + 1] === "\n");
|
|
100
|
+
}
|
|
101
|
+
/** Index of the end of a comment that starts at `index`, i.e. of the next newline. */
|
|
102
|
+
function commentEnd(raw, index) {
|
|
103
|
+
const end = raw.indexOf("\n", index);
|
|
104
|
+
return end === -1 ? raw.length : end;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Split one stored history entry into the command lines it contains.
|
|
108
|
+
*
|
|
109
|
+
* - A backslash before a newline is a continuation: the lines are joined with a space.
|
|
110
|
+
* - Any other newline outside quotes ends a command (pasted scripts).
|
|
111
|
+
* - A newline inside quotes stays part of that command.
|
|
112
|
+
* - A `#` that starts a word begins a comment, so an apostrophe in a comment
|
|
113
|
+
* cannot swallow the lines after it.
|
|
114
|
+
*
|
|
115
|
+
* Each line is normalized and empty ones are dropped.
|
|
116
|
+
*/
|
|
117
|
+
export function commandLines(raw) {
|
|
118
|
+
const lines = [];
|
|
119
|
+
let current = "";
|
|
120
|
+
let quote = "";
|
|
121
|
+
let i = 0;
|
|
122
|
+
while (i < raw.length) {
|
|
123
|
+
const char = raw[i];
|
|
124
|
+
if (char === "\\" && quote !== "'" && isLineBreak(raw, i + 1)) {
|
|
125
|
+
current += " ";
|
|
126
|
+
i += raw[i + 1] === "\r" ? 3 : 2;
|
|
127
|
+
}
|
|
128
|
+
else if (char === "\\" && quote !== "'" && i + 1 < raw.length) {
|
|
129
|
+
current += char + raw[i + 1];
|
|
130
|
+
i += 2;
|
|
131
|
+
}
|
|
132
|
+
else if (quote) {
|
|
133
|
+
if (char === quote)
|
|
134
|
+
quote = "";
|
|
135
|
+
current += char;
|
|
136
|
+
i++;
|
|
137
|
+
}
|
|
138
|
+
else if (char === "#" && (current === "" || /\s$/.test(current))) {
|
|
139
|
+
const end = commentEnd(raw, i);
|
|
140
|
+
current += raw.slice(i, end);
|
|
141
|
+
i = end;
|
|
142
|
+
}
|
|
143
|
+
else if (char === "'" || char === '"') {
|
|
144
|
+
quote = char;
|
|
145
|
+
current += char;
|
|
146
|
+
i++;
|
|
147
|
+
}
|
|
148
|
+
else if (isLineBreak(raw, i)) {
|
|
149
|
+
lines.push(current);
|
|
150
|
+
current = "";
|
|
151
|
+
i += char === "\r" ? 2 : 1;
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
current += char;
|
|
155
|
+
i++;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
lines.push(current);
|
|
159
|
+
return lines.map(normalizeCommand).filter((line) => line !== "");
|
|
160
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { VAR } from "./templates.js";
|
|
2
|
+
function posixQuote(text) {
|
|
3
|
+
return `'${text.replace(/'/g, `'\\''`)}'`;
|
|
4
|
+
}
|
|
5
|
+
function fishQuote(text) {
|
|
6
|
+
return `'${text.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`;
|
|
7
|
+
}
|
|
8
|
+
function functionBody(shell, command) {
|
|
9
|
+
const positional = shell === "fish" ? "$argv[1]" : '"$1"';
|
|
10
|
+
return command.split(VAR).join(positional);
|
|
11
|
+
}
|
|
12
|
+
/** Ready-to-paste definition for one suggestion in the given shell's dialect. */
|
|
13
|
+
export function renderDefinition(shell, item) {
|
|
14
|
+
if (item.kind === "alias") {
|
|
15
|
+
return shell === "fish"
|
|
16
|
+
? `alias ${item.name} ${fishQuote(item.command)}`
|
|
17
|
+
: `alias ${item.name}=${posixQuote(item.command)}`;
|
|
18
|
+
}
|
|
19
|
+
const body = functionBody(shell, item.command);
|
|
20
|
+
return shell === "fish"
|
|
21
|
+
? `function ${item.name}\n ${body}\nend`
|
|
22
|
+
: `${item.name}() { ${body}; }`;
|
|
23
|
+
}
|