vw-keystroke 0.1.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 PolyDevHouse LLC
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.
@@ -0,0 +1,123 @@
1
+ Metadata-Version: 2.4
2
+ Name: vw-keystroke
3
+ Version: 0.1.0
4
+ Summary: Drop a secret once β€” into Vaultwarden/Bitwarden and a local 600 env file.
5
+ Author: PolyDevHouse LLC
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/PolyDevHouse/vw-keystroke
8
+ Project-URL: Issues, https://github.com/PolyDevHouse/vw-keystroke/issues
9
+ Keywords: vaultwarden,bitwarden,secrets,api-keys,dotenv,keyring
10
+ Classifier: Environment :: X11 Applications
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Topic :: Security
15
+ Classifier: Topic :: Utilities
16
+ Requires-Python: >=3.9
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Requires-Dist: keyring>=24
20
+ Dynamic: license-file
21
+
22
+ <p align="center">
23
+ <img src="assets/rendered/vw-keystroke-256.png" width="120" alt="VW Keystroke">
24
+ </p>
25
+
26
+ <h1 align="center">VW Keystroke</h1>
27
+
28
+ <p align="center">
29
+ <img src="https://img.shields.io/github/license/PolyDevHouse/vw-keystroke" alt="License: MIT">
30
+ <img src="https://img.shields.io/badge/python-3.9%2B-blue" alt="Python 3.9+">
31
+ <img src="https://img.shields.io/badge/platform-Windows%20%7C%20Linux%20%7C%20macOS-lightgrey" alt="Platforms">
32
+ <img src="https://img.shields.io/badge/vault-Vaultwarden%20%7C%20Bitwarden-175DDC" alt="Vaultwarden / Bitwarden">
33
+ </p>
34
+
35
+ **Paste a secret once β€” it lands in your [Vaultwarden](https://github.com/dani-garcia/vaultwarden)/Bitwarden vault *and* a local `600` env file, in one move.**
36
+
37
+ Built for the "hot potato" moment: you just generated an API key and you want it
38
+ *off your clipboard and safely stored* without a five-step ritual β€” so it doesn't
39
+ end up lingering in a scratch file. Name it, paste it, done.
40
+
41
+ - πŸ—„οΈ Saves to your vault as a **Login** item (username = the env var, password = the value, with the env-file path recorded as a custom field).
42
+ - πŸ“„ Writes `~/.config/<project>.env` (`chmod 600`, updated in place β€” never duplicated).
43
+ - πŸ”’ **Zero-knowledge**: your master password never leaves your machine; all vault crypto is done by the official `bw` CLI.
44
+ - ⚑ Caches the unlocked session in your **OS keyring** (Windows Credential Locker / macOS Keychain / Linux libsecret) so daily use needs no password.
45
+ - πŸ–₯️ **GUI** and **CLI**, on **Windows / Linux / macOS**.
46
+
47
+ > The secret value is never passed on the command line and never echoed β€” only
48
+ > the `600` env file and your vault ever see it.
49
+
50
+ ---
51
+
52
+ ## Requirements
53
+
54
+ 1. **[Bitwarden CLI](https://bitwarden.com/help/cli/)** β€” `npm install -g @bitwarden/cli` (provides `bw`).
55
+ 2. **Python 3.9+** (Tkinter ships with the standard python.org installers).
56
+
57
+ ## Install
58
+
59
+ ```bash
60
+ # From GitHub (works today):
61
+ pipx install git+https://github.com/PolyDevHouse/vw-keystroke
62
+ # or
63
+ pip install --user git+https://github.com/PolyDevHouse/vw-keystroke
64
+ ```
65
+
66
+ > _PyPI release coming soon β€” then `pipx install vw-keystroke` will Just Work._
67
+
68
+ This gives you three commands: `vwks` (CLI), `keydrop` (alias), and `vwks-gui` (GUI).
69
+
70
+ ### First-time setup
71
+
72
+ ```bash
73
+ # point bw at your server (self-hosted Vaultwarden, or Bitwarden cloud)
74
+ bw config server https://vault.example.com
75
+ bw login # email + master password + 2FA (once)
76
+
77
+ # optional config
78
+ mkdir -p ~/.config
79
+ cp vw-keystroke.conf.example ~/.config/vw-keystroke.conf && chmod 600 ~/.config/vw-keystroke.conf
80
+ ```
81
+
82
+ ## Use
83
+
84
+ **GUI:** launch **VW Keystroke** from your app menu (Linux `.desktop` in
85
+ [`packaging/`](packaging/)), or run `vwks-gui`.
86
+
87
+ **CLI:**
88
+ ```bash
89
+ vwks OPENAI_API_KEY # prompts (hidden) for the value
90
+ vwks OPENAI_API_KEY --project openai # -> ~/.config/openai.env
91
+ echo -n "$SECRET" | vwks --var FOO --project bar # piped / non-interactive
92
+ vwks --lock # forget the cached session + lock now
93
+ ```
94
+
95
+ ## Configuration
96
+
97
+ `~/.config/vw-keystroke.conf` (all optional β€” see [`vw-keystroke.conf.example`](vw-keystroke.conf.example)):
98
+
99
+ | Key | Meaning | Default |
100
+ |-----|---------|---------|
101
+ | `BW_SERVER` | Vault server URL | current `bw` config |
102
+ | `DEFAULT_PROJECT` | env-file basename | `keys` |
103
+ | `VAULT_FOLDER` | folder for new items | `API Keys` |
104
+ | `KEYRING_TTL_DAYS` | cached-session lifetime (`0` = never cache) | `30` |
105
+ | `ENV_DIR` | where env files go | `~/.config` |
106
+ | `BW_CLIENTID` / `BW_CLIENTSECRET` | optional API-key login | β€” |
107
+
108
+ ## How it works
109
+
110
+ `bw` handles login and all encryption. On a drop, VW Keystroke unlocks (or reuses a
111
+ cached session), then creates/updates the vault item and the env file. If the vault
112
+ is locked it asks for your master password once; the resulting session key is stored
113
+ in your OS keyring with a TTL and refreshed on use.
114
+
115
+ ## Security notes
116
+
117
+ - Nothing sensitive is stored by this tool except the `600` env file you asked for and the keyring session (same protection as your desktop login).
118
+ - A cached session means anyone at your already-unlocked machine can drop/read keys β€” the same exposure as a logged-in browser vault extension. Set `KEYRING_TTL_DAYS=0` to disable caching.
119
+ - This project ships **no** credentials, servers, or keys of its own.
120
+
121
+ ## License
122
+
123
+ MIT Β© PolyDevHouse LLC
@@ -0,0 +1,102 @@
1
+ <p align="center">
2
+ <img src="assets/rendered/vw-keystroke-256.png" width="120" alt="VW Keystroke">
3
+ </p>
4
+
5
+ <h1 align="center">VW Keystroke</h1>
6
+
7
+ <p align="center">
8
+ <img src="https://img.shields.io/github/license/PolyDevHouse/vw-keystroke" alt="License: MIT">
9
+ <img src="https://img.shields.io/badge/python-3.9%2B-blue" alt="Python 3.9+">
10
+ <img src="https://img.shields.io/badge/platform-Windows%20%7C%20Linux%20%7C%20macOS-lightgrey" alt="Platforms">
11
+ <img src="https://img.shields.io/badge/vault-Vaultwarden%20%7C%20Bitwarden-175DDC" alt="Vaultwarden / Bitwarden">
12
+ </p>
13
+
14
+ **Paste a secret once β€” it lands in your [Vaultwarden](https://github.com/dani-garcia/vaultwarden)/Bitwarden vault *and* a local `600` env file, in one move.**
15
+
16
+ Built for the "hot potato" moment: you just generated an API key and you want it
17
+ *off your clipboard and safely stored* without a five-step ritual β€” so it doesn't
18
+ end up lingering in a scratch file. Name it, paste it, done.
19
+
20
+ - πŸ—„οΈ Saves to your vault as a **Login** item (username = the env var, password = the value, with the env-file path recorded as a custom field).
21
+ - πŸ“„ Writes `~/.config/<project>.env` (`chmod 600`, updated in place β€” never duplicated).
22
+ - πŸ”’ **Zero-knowledge**: your master password never leaves your machine; all vault crypto is done by the official `bw` CLI.
23
+ - ⚑ Caches the unlocked session in your **OS keyring** (Windows Credential Locker / macOS Keychain / Linux libsecret) so daily use needs no password.
24
+ - πŸ–₯️ **GUI** and **CLI**, on **Windows / Linux / macOS**.
25
+
26
+ > The secret value is never passed on the command line and never echoed β€” only
27
+ > the `600` env file and your vault ever see it.
28
+
29
+ ---
30
+
31
+ ## Requirements
32
+
33
+ 1. **[Bitwarden CLI](https://bitwarden.com/help/cli/)** β€” `npm install -g @bitwarden/cli` (provides `bw`).
34
+ 2. **Python 3.9+** (Tkinter ships with the standard python.org installers).
35
+
36
+ ## Install
37
+
38
+ ```bash
39
+ # From GitHub (works today):
40
+ pipx install git+https://github.com/PolyDevHouse/vw-keystroke
41
+ # or
42
+ pip install --user git+https://github.com/PolyDevHouse/vw-keystroke
43
+ ```
44
+
45
+ > _PyPI release coming soon β€” then `pipx install vw-keystroke` will Just Work._
46
+
47
+ This gives you three commands: `vwks` (CLI), `keydrop` (alias), and `vwks-gui` (GUI).
48
+
49
+ ### First-time setup
50
+
51
+ ```bash
52
+ # point bw at your server (self-hosted Vaultwarden, or Bitwarden cloud)
53
+ bw config server https://vault.example.com
54
+ bw login # email + master password + 2FA (once)
55
+
56
+ # optional config
57
+ mkdir -p ~/.config
58
+ cp vw-keystroke.conf.example ~/.config/vw-keystroke.conf && chmod 600 ~/.config/vw-keystroke.conf
59
+ ```
60
+
61
+ ## Use
62
+
63
+ **GUI:** launch **VW Keystroke** from your app menu (Linux `.desktop` in
64
+ [`packaging/`](packaging/)), or run `vwks-gui`.
65
+
66
+ **CLI:**
67
+ ```bash
68
+ vwks OPENAI_API_KEY # prompts (hidden) for the value
69
+ vwks OPENAI_API_KEY --project openai # -> ~/.config/openai.env
70
+ echo -n "$SECRET" | vwks --var FOO --project bar # piped / non-interactive
71
+ vwks --lock # forget the cached session + lock now
72
+ ```
73
+
74
+ ## Configuration
75
+
76
+ `~/.config/vw-keystroke.conf` (all optional β€” see [`vw-keystroke.conf.example`](vw-keystroke.conf.example)):
77
+
78
+ | Key | Meaning | Default |
79
+ |-----|---------|---------|
80
+ | `BW_SERVER` | Vault server URL | current `bw` config |
81
+ | `DEFAULT_PROJECT` | env-file basename | `keys` |
82
+ | `VAULT_FOLDER` | folder for new items | `API Keys` |
83
+ | `KEYRING_TTL_DAYS` | cached-session lifetime (`0` = never cache) | `30` |
84
+ | `ENV_DIR` | where env files go | `~/.config` |
85
+ | `BW_CLIENTID` / `BW_CLIENTSECRET` | optional API-key login | β€” |
86
+
87
+ ## How it works
88
+
89
+ `bw` handles login and all encryption. On a drop, VW Keystroke unlocks (or reuses a
90
+ cached session), then creates/updates the vault item and the env file. If the vault
91
+ is locked it asks for your master password once; the resulting session key is stored
92
+ in your OS keyring with a TTL and refreshed on use.
93
+
94
+ ## Security notes
95
+
96
+ - Nothing sensitive is stored by this tool except the `600` env file you asked for and the keyring session (same protection as your desktop login).
97
+ - A cached session means anyone at your already-unlocked machine can drop/read keys β€” the same exposure as a logged-in browser vault extension. Set `KEYRING_TTL_DAYS=0` to disable caching.
98
+ - This project ships **no** credentials, servers, or keys of its own.
99
+
100
+ ## License
101
+
102
+ MIT Β© PolyDevHouse LLC
@@ -0,0 +1,36 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "vw-keystroke"
7
+ version = "0.1.0"
8
+ description = "Drop a secret once β€” into Vaultwarden/Bitwarden and a local 600 env file."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "PolyDevHouse LLC" }]
13
+ keywords = ["vaultwarden", "bitwarden", "secrets", "api-keys", "dotenv", "keyring"]
14
+ classifiers = [
15
+ "Environment :: X11 Applications",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Operating System :: OS Independent",
19
+ "Topic :: Security",
20
+ "Topic :: Utilities",
21
+ ]
22
+ dependencies = ["keyring>=24"]
23
+
24
+ [project.urls]
25
+ Homepage = "https://github.com/PolyDevHouse/vw-keystroke"
26
+ Issues = "https://github.com/PolyDevHouse/vw-keystroke/issues"
27
+
28
+ [project.scripts]
29
+ vwks = "vwkeystroke.cli:main"
30
+ keydrop = "vwkeystroke.cli:main"
31
+
32
+ [project.gui-scripts]
33
+ vwks-gui = "vwkeystroke.gui:main"
34
+
35
+ [tool.setuptools]
36
+ packages = ["vwkeystroke"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,123 @@
1
+ Metadata-Version: 2.4
2
+ Name: vw-keystroke
3
+ Version: 0.1.0
4
+ Summary: Drop a secret once β€” into Vaultwarden/Bitwarden and a local 600 env file.
5
+ Author: PolyDevHouse LLC
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/PolyDevHouse/vw-keystroke
8
+ Project-URL: Issues, https://github.com/PolyDevHouse/vw-keystroke/issues
9
+ Keywords: vaultwarden,bitwarden,secrets,api-keys,dotenv,keyring
10
+ Classifier: Environment :: X11 Applications
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Topic :: Security
15
+ Classifier: Topic :: Utilities
16
+ Requires-Python: >=3.9
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Requires-Dist: keyring>=24
20
+ Dynamic: license-file
21
+
22
+ <p align="center">
23
+ <img src="assets/rendered/vw-keystroke-256.png" width="120" alt="VW Keystroke">
24
+ </p>
25
+
26
+ <h1 align="center">VW Keystroke</h1>
27
+
28
+ <p align="center">
29
+ <img src="https://img.shields.io/github/license/PolyDevHouse/vw-keystroke" alt="License: MIT">
30
+ <img src="https://img.shields.io/badge/python-3.9%2B-blue" alt="Python 3.9+">
31
+ <img src="https://img.shields.io/badge/platform-Windows%20%7C%20Linux%20%7C%20macOS-lightgrey" alt="Platforms">
32
+ <img src="https://img.shields.io/badge/vault-Vaultwarden%20%7C%20Bitwarden-175DDC" alt="Vaultwarden / Bitwarden">
33
+ </p>
34
+
35
+ **Paste a secret once β€” it lands in your [Vaultwarden](https://github.com/dani-garcia/vaultwarden)/Bitwarden vault *and* a local `600` env file, in one move.**
36
+
37
+ Built for the "hot potato" moment: you just generated an API key and you want it
38
+ *off your clipboard and safely stored* without a five-step ritual β€” so it doesn't
39
+ end up lingering in a scratch file. Name it, paste it, done.
40
+
41
+ - πŸ—„οΈ Saves to your vault as a **Login** item (username = the env var, password = the value, with the env-file path recorded as a custom field).
42
+ - πŸ“„ Writes `~/.config/<project>.env` (`chmod 600`, updated in place β€” never duplicated).
43
+ - πŸ”’ **Zero-knowledge**: your master password never leaves your machine; all vault crypto is done by the official `bw` CLI.
44
+ - ⚑ Caches the unlocked session in your **OS keyring** (Windows Credential Locker / macOS Keychain / Linux libsecret) so daily use needs no password.
45
+ - πŸ–₯️ **GUI** and **CLI**, on **Windows / Linux / macOS**.
46
+
47
+ > The secret value is never passed on the command line and never echoed β€” only
48
+ > the `600` env file and your vault ever see it.
49
+
50
+ ---
51
+
52
+ ## Requirements
53
+
54
+ 1. **[Bitwarden CLI](https://bitwarden.com/help/cli/)** β€” `npm install -g @bitwarden/cli` (provides `bw`).
55
+ 2. **Python 3.9+** (Tkinter ships with the standard python.org installers).
56
+
57
+ ## Install
58
+
59
+ ```bash
60
+ # From GitHub (works today):
61
+ pipx install git+https://github.com/PolyDevHouse/vw-keystroke
62
+ # or
63
+ pip install --user git+https://github.com/PolyDevHouse/vw-keystroke
64
+ ```
65
+
66
+ > _PyPI release coming soon β€” then `pipx install vw-keystroke` will Just Work._
67
+
68
+ This gives you three commands: `vwks` (CLI), `keydrop` (alias), and `vwks-gui` (GUI).
69
+
70
+ ### First-time setup
71
+
72
+ ```bash
73
+ # point bw at your server (self-hosted Vaultwarden, or Bitwarden cloud)
74
+ bw config server https://vault.example.com
75
+ bw login # email + master password + 2FA (once)
76
+
77
+ # optional config
78
+ mkdir -p ~/.config
79
+ cp vw-keystroke.conf.example ~/.config/vw-keystroke.conf && chmod 600 ~/.config/vw-keystroke.conf
80
+ ```
81
+
82
+ ## Use
83
+
84
+ **GUI:** launch **VW Keystroke** from your app menu (Linux `.desktop` in
85
+ [`packaging/`](packaging/)), or run `vwks-gui`.
86
+
87
+ **CLI:**
88
+ ```bash
89
+ vwks OPENAI_API_KEY # prompts (hidden) for the value
90
+ vwks OPENAI_API_KEY --project openai # -> ~/.config/openai.env
91
+ echo -n "$SECRET" | vwks --var FOO --project bar # piped / non-interactive
92
+ vwks --lock # forget the cached session + lock now
93
+ ```
94
+
95
+ ## Configuration
96
+
97
+ `~/.config/vw-keystroke.conf` (all optional β€” see [`vw-keystroke.conf.example`](vw-keystroke.conf.example)):
98
+
99
+ | Key | Meaning | Default |
100
+ |-----|---------|---------|
101
+ | `BW_SERVER` | Vault server URL | current `bw` config |
102
+ | `DEFAULT_PROJECT` | env-file basename | `keys` |
103
+ | `VAULT_FOLDER` | folder for new items | `API Keys` |
104
+ | `KEYRING_TTL_DAYS` | cached-session lifetime (`0` = never cache) | `30` |
105
+ | `ENV_DIR` | where env files go | `~/.config` |
106
+ | `BW_CLIENTID` / `BW_CLIENTSECRET` | optional API-key login | β€” |
107
+
108
+ ## How it works
109
+
110
+ `bw` handles login and all encryption. On a drop, VW Keystroke unlocks (or reuses a
111
+ cached session), then creates/updates the vault item and the env file. If the vault
112
+ is locked it asks for your master password once; the resulting session key is stored
113
+ in your OS keyring with a TTL and refreshed on use.
114
+
115
+ ## Security notes
116
+
117
+ - Nothing sensitive is stored by this tool except the `600` env file you asked for and the keyring session (same protection as your desktop login).
118
+ - A cached session means anyone at your already-unlocked machine can drop/read keys β€” the same exposure as a logged-in browser vault extension. Set `KEYRING_TTL_DAYS=0` to disable caching.
119
+ - This project ships **no** credentials, servers, or keys of its own.
120
+
121
+ ## License
122
+
123
+ MIT Β© PolyDevHouse LLC
@@ -0,0 +1,13 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ vw_keystroke.egg-info/PKG-INFO
5
+ vw_keystroke.egg-info/SOURCES.txt
6
+ vw_keystroke.egg-info/dependency_links.txt
7
+ vw_keystroke.egg-info/entry_points.txt
8
+ vw_keystroke.egg-info/requires.txt
9
+ vw_keystroke.egg-info/top_level.txt
10
+ vwkeystroke/__init__.py
11
+ vwkeystroke/cli.py
12
+ vwkeystroke/core.py
13
+ vwkeystroke/gui.py
@@ -0,0 +1,6 @@
1
+ [console_scripts]
2
+ keydrop = vwkeystroke.cli:main
3
+ vwks = vwkeystroke.cli:main
4
+
5
+ [gui_scripts]
6
+ vwks-gui = vwkeystroke.gui:main
@@ -0,0 +1 @@
1
+ keyring>=24
@@ -0,0 +1 @@
1
+ vwkeystroke
@@ -0,0 +1,2 @@
1
+ """VW Keystroke β€” drop a secret once, into Vaultwarden + a local 600 env file."""
2
+ __version__ = "0.1.0"
@@ -0,0 +1,76 @@
1
+ #!/usr/bin/env python3
2
+ """VW Keystroke CLI β€” `vwks` (alias `keydrop`).
3
+
4
+ vwks OPENAI_API_KEY # positional var, prompts for value
5
+ vwks --var FOO --project bar
6
+ echo -n "$SECRET" | vwks --var FOO --project bar # non-interactive
7
+ vwks --lock # forget cached session + lock
8
+ """
9
+ import argparse, sys
10
+ from getpass import getpass
11
+ from . import core
12
+
13
+
14
+ def _read_value():
15
+ if not sys.stdin.isatty():
16
+ v = sys.stdin.readline().rstrip("\n")
17
+ else:
18
+ v = getpass("Paste value (hidden): ")
19
+ if not v:
20
+ sys.exit("vwks: empty value β€” nothing to store")
21
+ return v
22
+
23
+
24
+ def main(argv=None):
25
+ ap = argparse.ArgumentParser(prog="vwks",
26
+ description="Store a secret in a 600 env file + Vaultwarden/Bitwarden.")
27
+ ap.add_argument("var_pos", nargs="?", help="ENV var name (positional shortcut)")
28
+ ap.add_argument("--var")
29
+ ap.add_argument("--project")
30
+ ap.add_argument("--name", help="vault item name (default: the ENV var name)")
31
+ ap.add_argument("--folder")
32
+ ap.add_argument("--env-dir")
33
+ ap.add_argument("--no-vault", action="store_true", help="write env file only")
34
+ ap.add_argument("--lock", action="store_true", help="forget cached session + lock, then exit")
35
+ a = ap.parse_args(argv)
36
+
37
+ if a.lock:
38
+ core.lock()
39
+ print("β†’ cached session cleared; vault locked")
40
+ return 0
41
+
42
+ conf = core.load_conf()
43
+ interactive = sys.stdin.isatty()
44
+ var = a.var or a.var_pos or (input("Env var name: ").strip() if interactive else "")
45
+ if not core.valid_var(var):
46
+ sys.exit(f"vwks: invalid/empty env var name: {var!r}")
47
+ proj_default = core.default_project(conf)
48
+ project = a.project or (input(f"Project [{proj_default}]: ").strip() if interactive else "") or proj_default
49
+
50
+ value = _read_value()
51
+
52
+ mp = None
53
+ while True:
54
+ try:
55
+ res = core.drop(var, value, project=project, name=a.name, folder=a.folder,
56
+ env_dir_=a.env_dir, no_vault=a.no_vault, master_password=mp, conf=conf)
57
+ break
58
+ except core.NeedUnlock:
59
+ if not interactive:
60
+ sys.exit("vwks: vault locked and no TTY to prompt for master password")
61
+ mp = getpass("Vault master password (hidden): ")
62
+ except core.NeedLogin as e:
63
+ sys.exit(f"vwks: {e}")
64
+ except core.VwksError as e:
65
+ sys.exit(f"vwks: {e}")
66
+
67
+ print(f"β†’ {res['env_action']} {var} in {res['env_path']} (600)")
68
+ if res["vault"]:
69
+ print(f'β†’ Vaultwarden: {res["vault"]} login "{a.name or var}" in folder "{res["folder"]}" βœ“')
70
+ else:
71
+ print("β†’ vault skipped (--no-vault)")
72
+ return 0
73
+
74
+
75
+ if __name__ == "__main__":
76
+ raise SystemExit(main())
@@ -0,0 +1,347 @@
1
+ """VW Keystroke β€” shared core for the CLI and GUI.
2
+
3
+ Paste a secret once; it lands in a 600 env file AND in your Vaultwarden (or
4
+ Bitwarden) vault. Wraps the `bw` CLI (which handles all the vault crypto). The
5
+ unlocked session is cached in the OS keyring (Windows Credential Locker / macOS
6
+ Keychain / libsecret) so day-to-day use is just: name it, paste it.
7
+
8
+ No secret value is ever passed on argv or written anywhere but the env file
9
+ (600) and the vault.
10
+ """
11
+ from __future__ import annotations
12
+ import base64, json, os, re, shutil, subprocess, tempfile, time
13
+
14
+ try:
15
+ import keyring as _keyring # cross-platform session cache
16
+ except Exception: # pragma: no cover - optional
17
+ _keyring = None
18
+
19
+ APP = "vw-keystroke"
20
+ KR_SERVICE = "vw-keystroke"
21
+ KR_ACCOUNT = "bw-session"
22
+
23
+ # Config search order: explicit env var, XDG-ish path, then the legacy keydrop name.
24
+ CONFIG_PATHS = [
25
+ os.environ.get("VWKS_CONFIG"),
26
+ os.path.expanduser("~/.config/vw-keystroke.conf"),
27
+ os.path.expanduser("~/.config/keydrop.conf"),
28
+ ]
29
+
30
+
31
+ def _dbg(msg: str):
32
+ if not os.environ.get("VWKS_DEBUG"):
33
+ return
34
+ try:
35
+ with open("/tmp/vwks-debug.log", "a", encoding="utf-8") as f:
36
+ f.write(f"{time.time():.0f} {msg}\n")
37
+ except Exception:
38
+ pass
39
+
40
+
41
+ class VwksError(Exception):
42
+ pass
43
+
44
+
45
+ class NeedUnlock(VwksError):
46
+ """Vault is logged in but locked β€” a master password is required."""
47
+
48
+
49
+ class NeedLogin(VwksError):
50
+ """Not logged in and no API key configured β€” run `bw login` once."""
51
+
52
+
53
+ # ---------- config ----------
54
+ def config_path() -> str:
55
+ for p in CONFIG_PATHS:
56
+ if p and os.path.exists(p):
57
+ return p
58
+ return os.path.expanduser("~/.config/vw-keystroke.conf")
59
+
60
+
61
+ def load_conf() -> dict:
62
+ conf = {}
63
+ p = config_path()
64
+ if os.path.exists(p):
65
+ for ln in open(p, encoding="utf-8"):
66
+ ln = ln.strip()
67
+ if ln and not ln.startswith("#") and "=" in ln:
68
+ k, v = ln.split("=", 1)
69
+ conf[k.strip()] = v.strip().strip('"').strip("'")
70
+ return conf
71
+
72
+
73
+ def env_dir(conf: dict | None = None) -> str:
74
+ conf = conf or {}
75
+ return os.path.expanduser(conf.get("ENV_DIR") or "~/.config")
76
+
77
+
78
+ def default_project(conf: dict) -> str:
79
+ return conf.get("DEFAULT_PROJECT", "keys")
80
+
81
+
82
+ def list_projects(edir: str) -> list[str]:
83
+ try:
84
+ return sorted(f[:-4] for f in os.listdir(edir) if f.endswith(".env"))
85
+ except FileNotFoundError:
86
+ return []
87
+
88
+
89
+ # ---------- env file ----------
90
+ def valid_var(var: str) -> bool:
91
+ return bool(re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", var or ""))
92
+
93
+
94
+ def write_env(edir: str, project: str, var: str, value: str) -> tuple[str, str]:
95
+ path = os.path.join(os.path.expanduser(edir), f"{project}.env")
96
+ lines = open(path, encoding="utf-8").read().splitlines() if os.path.exists(path) else []
97
+ out, found = [], False
98
+ pat = re.compile(rf"^\s*{re.escape(var)}\s*=")
99
+ for ln in lines:
100
+ if pat.match(ln):
101
+ out.append(f"{var}={value}"); found = True
102
+ else:
103
+ out.append(ln)
104
+ if not found:
105
+ out.append(f"{var}={value}")
106
+ d = os.path.dirname(path) or "."
107
+ os.makedirs(d, exist_ok=True)
108
+ fd, tmp = tempfile.mkstemp(dir=d)
109
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
110
+ f.write("\n".join(out) + "\n")
111
+ try:
112
+ os.chmod(tmp, 0o600)
113
+ except OSError:
114
+ pass # Windows: perms are a no-op
115
+ os.replace(tmp, path)
116
+ try:
117
+ os.chmod(path, 0o600)
118
+ except OSError:
119
+ pass
120
+ return path, ("updated" if found else "added")
121
+
122
+
123
+ # ---------- bw plumbing ----------
124
+ def find_bw() -> str:
125
+ b = shutil.which("bw")
126
+ if b:
127
+ return b
128
+ for cand in [
129
+ os.path.expanduser("~/.npm-global/bin/bw"),
130
+ "/usr/local/bin/bw",
131
+ os.path.expandvars(r"%APPDATA%\npm\bw.cmd"),
132
+ ]:
133
+ if cand and os.path.exists(cand):
134
+ return cand
135
+ raise VwksError("bw CLI not found. Install it: npm install -g @bitwarden/cli")
136
+
137
+
138
+ def _run(bw, args, env, check=True):
139
+ r = subprocess.run([bw] + args, capture_output=True, text=True, env=env)
140
+ if check and r.returncode != 0:
141
+ raise VwksError(f"bw {args[0]} failed: {r.stderr.strip()[:200]}")
142
+ return r
143
+
144
+
145
+ def bw_status(bw, env) -> str:
146
+ r = _run(bw, ["status"], env, check=False)
147
+ try:
148
+ return json.loads(r.stdout).get("status", "unknown")
149
+ except Exception:
150
+ return "unknown"
151
+
152
+
153
+ def _base_env(conf) -> dict:
154
+ return dict(os.environ)
155
+
156
+
157
+ def _ensure_server(bw, conf, env):
158
+ url = conf.get("BW_SERVER")
159
+ if url:
160
+ _run(bw, ["config", "server", url], env, check=False)
161
+
162
+
163
+ # ---------- keyring session cache ----------
164
+ def _ttl_days(conf) -> int:
165
+ try:
166
+ return int(conf.get("KEYRING_TTL_DAYS", "30") or 0)
167
+ except ValueError:
168
+ return 30
169
+
170
+
171
+ def kr_get() -> str | None:
172
+ if not _keyring:
173
+ return None
174
+ try:
175
+ raw = _keyring.get_password(KR_SERVICE, KR_ACCOUNT)
176
+ except Exception:
177
+ return None
178
+ if not raw:
179
+ return None
180
+ try:
181
+ d = json.loads(raw)
182
+ except Exception:
183
+ return None
184
+ if d.get("exp", 0) < time.time():
185
+ kr_clear()
186
+ return None
187
+ return d.get("session")
188
+
189
+
190
+ def kr_store(session: str, ttl_days: int):
191
+ if not _keyring or ttl_days <= 0 or not session:
192
+ return
193
+ try:
194
+ _keyring.set_password(KR_SERVICE, KR_ACCOUNT,
195
+ json.dumps({"session": session, "exp": time.time() + ttl_days * 86400}))
196
+ except Exception:
197
+ pass
198
+
199
+
200
+ def kr_clear():
201
+ if not _keyring:
202
+ return
203
+ try:
204
+ _keyring.delete_password(KR_SERVICE, KR_ACCOUNT)
205
+ except Exception:
206
+ pass
207
+
208
+
209
+ def _session_ok(bw, base_env, sess) -> bool:
210
+ """Validate a session by running a real op that requires an unlocked vault.
211
+ `bw status` is unreliable here β€” recent bw versions report the on-disk lock
212
+ state and return "locked" even with a valid session in the environment, so we
213
+ probe with `bw list folders` instead. stdin is closed so an invalid session
214
+ fails fast (returns non-zero) rather than dropping into an interactive prompt."""
215
+ if not sess:
216
+ return False
217
+ try:
218
+ r = subprocess.run([bw, "list", "folders", "--session", sess],
219
+ env=dict(base_env, BW_SESSION=sess),
220
+ capture_output=True, text=True,
221
+ stdin=subprocess.DEVNULL, timeout=30)
222
+ except Exception:
223
+ return False
224
+ return r.returncode == 0 and r.stdout.strip().startswith("[")
225
+
226
+
227
+ def session_state(conf: dict | None = None) -> str:
228
+ """'ready' (cached session), 'locked' (need master pw), 'apikey' (can auto-login),
229
+ 'login' (need one-time `bw login`)."""
230
+ conf = conf or load_conf()
231
+ try:
232
+ bw = find_bw()
233
+ except VwksError:
234
+ return "login"
235
+ base = _base_env(conf)
236
+ if _session_ok(bw, base, kr_get()):
237
+ return "ready"
238
+ if bw_status(bw, base) == "unauthenticated":
239
+ cid, csec = conf.get("BW_CLIENTID"), conf.get("BW_CLIENTSECRET")
240
+ return "apikey" if (cid and csec and "xxxx" not in (cid + csec)) else "login"
241
+ return "locked"
242
+
243
+
244
+ def _ensure_session(bw, base_env, conf, master_password=None) -> str:
245
+ sess = kr_get()
246
+ _dbg(f"ensure: kr_get -> {'HIT' if sess else 'miss'}; keyring={bool(_keyring)}")
247
+ if _session_ok(bw, base_env, sess):
248
+ _dbg("ensure: cached session VALID -> reuse")
249
+ kr_store(sess, _ttl_days(conf)) # idle-refresh
250
+ return sess
251
+ st = bw_status(bw, base_env)
252
+ _dbg(f"ensure: cache miss/invalid; bw status(no-session)={st}")
253
+ if st == "unauthenticated":
254
+ _ensure_server(bw, conf, base_env)
255
+ cid, csec = conf.get("BW_CLIENTID"), conf.get("BW_CLIENTSECRET")
256
+ if cid and csec and "xxxx" not in (cid + csec):
257
+ _run(bw, ["login", "--apikey"], dict(base_env, BW_CLIENTID=cid, BW_CLIENTSECRET=csec))
258
+ else:
259
+ raise NeedLogin("Not logged in. Run `bw login` once in a terminal, then retry.")
260
+ if master_password is None:
261
+ _dbg("ensure: NeedUnlock (no master password provided)")
262
+ raise NeedUnlock("Vault is locked β€” a master password is required.")
263
+ r = _run(bw, ["unlock", "--passwordenv", "BW_PASSWORD", "--raw"],
264
+ dict(base_env, BW_PASSWORD=master_password))
265
+ sess = r.stdout.strip()
266
+ _dbg(f"ensure: unlock done; session_len={len(sess)}")
267
+ if not sess:
268
+ raise VwksError("unlock returned no session (wrong master password?)")
269
+ kr_store(sess, _ttl_days(conf))
270
+ back = kr_get()
271
+ _dbg(f"ensure: kr_store done; readback={'OK' if back == sess else 'MISMATCH/none'}; "
272
+ f"revalidate={_session_ok(bw, base_env, sess)}")
273
+ return sess
274
+
275
+
276
+ # ---------- vault item upsert ----------
277
+ def _folder_id(bw, env, name):
278
+ if not name:
279
+ return None
280
+ r = _run(bw, ["list", "folders", "--search", name], env)
281
+ for f in json.loads(r.stdout or "[]"):
282
+ if f.get("name") == name:
283
+ return f["id"]
284
+ enc = base64.b64encode(json.dumps({"name": name}).encode()).decode()
285
+ return json.loads(_run(bw, ["create", "folder", enc], env).stdout)["id"]
286
+
287
+
288
+ def _find_item(bw, env, name, var):
289
+ r = _run(bw, ["list", "items", "--search", name], env)
290
+ for it in json.loads(r.stdout or "[]"):
291
+ if it.get("name") == name and (it.get("login") or {}).get("username") in (var, None, ""):
292
+ return it
293
+ return None
294
+
295
+
296
+ def _vault_upsert(bw, env, name, var, value, env_path, fid):
297
+ field = {"name": "env_file", "value": env_path, "type": 0, "linkedId": None}
298
+ existing = _find_item(bw, env, name, var)
299
+ if existing:
300
+ it = existing
301
+ it["login"] = {**(it.get("login") or {}), "username": var, "password": value}
302
+ it["fields"] = [f for f in (it.get("fields") or []) if f.get("name") != "env_file"] + [field]
303
+ it["folderId"] = it.get("folderId") or fid
304
+ enc = base64.b64encode(json.dumps(it).encode()).decode()
305
+ _run(bw, ["edit", "item", it["id"], enc], env)
306
+ return "updated"
307
+ it = {"organizationId": None, "collectionIds": None, "folderId": fid, "type": 1,
308
+ "name": name, "notes": None, "favorite": False, "fields": [field],
309
+ "login": {"username": var, "password": value, "totp": None, "uris": []}, "reprompt": 0}
310
+ enc = base64.b64encode(json.dumps(it).encode()).decode()
311
+ _run(bw, ["create", "item", enc], env)
312
+ return "created"
313
+
314
+
315
+ # ---------- top-level ----------
316
+ def drop(var, value, *, project, name=None, folder=None, env_dir_=None,
317
+ no_vault=False, master_password=None, conf=None) -> dict:
318
+ """Write the secret to the env file and (unless no_vault) upsert it into the vault.
319
+ May raise NeedUnlock / NeedLogin so the caller can prompt and retry."""
320
+ conf = conf if conf is not None else load_conf()
321
+ if not valid_var(var):
322
+ raise VwksError(f"invalid env var name: {var!r}")
323
+ edir = env_dir_ or env_dir(conf)
324
+ path, action = write_env(edir, project, var, value)
325
+ result = {"env_path": path, "env_action": action, "vault": None, "folder": None}
326
+ if no_vault:
327
+ return result
328
+ bw = find_bw()
329
+ base_env = _base_env(conf)
330
+ sess = _ensure_session(bw, base_env, conf, master_password)
331
+ venv = dict(base_env, BW_SESSION=sess)
332
+ _run(bw, ["sync"], venv, check=False)
333
+ folder = folder or conf.get("VAULT_FOLDER", "API Keys")
334
+ fid = _folder_id(bw, venv, folder)
335
+ result["vault"] = _vault_upsert(bw, venv, name or var, var, value, path, fid)
336
+ result["folder"] = folder
337
+ return result
338
+
339
+
340
+ def lock():
341
+ """Forget the cached session and lock the vault."""
342
+ kr_clear()
343
+ try:
344
+ bw = find_bw()
345
+ _run(bw, ["lock"], _base_env(load_conf()), check=False)
346
+ except VwksError:
347
+ pass
@@ -0,0 +1,252 @@
1
+ #!/usr/bin/env python3
2
+ """VW Keystroke GUI β€” a small dark Tkinter window: name it, paste it, drop it.
3
+
4
+ Writes the secret to a 600 env file and (optionally) upserts it into Vaultwarden/
5
+ Bitwarden. Vault work runs on a worker thread so the UI never freezes.
6
+ """
7
+ import threading
8
+ import tkinter as tk
9
+ from tkinter import ttk, messagebox, simpledialog
10
+ from . import core
11
+
12
+ BG = "#0f1420"
13
+ CARD = "#161c28"
14
+ FIELD = "#0b0f17"
15
+ FG = "#e6edf3"
16
+ MUTED = "#8b97a7"
17
+ ACCENT = "#2fbf71"
18
+ ACCENT_HI = "#3ad684"
19
+ DANGER = "#e5534b"
20
+ BORDER = "#243044"
21
+ FONT = ("Segoe UI", 11) if tk.TkVersion else ("sans-serif", 11)
22
+ MONO = ("JetBrains Mono", 10)
23
+
24
+
25
+ class App(tk.Tk):
26
+ def __init__(self):
27
+ super().__init__()
28
+ self.title("VW Keystroke")
29
+ self.configure(bg=BG)
30
+ self.resizable(False, False)
31
+ try:
32
+ self.tk.call("tk", "scaling", 1.25)
33
+ except Exception:
34
+ pass
35
+ self.conf = core.load_conf()
36
+ self._busy = False
37
+ self._build()
38
+ self._refresh_status()
39
+
40
+ # ---------- UI ----------
41
+ def _label(self, parent, text):
42
+ return tk.Label(parent, text=text, bg=CARD, fg=MUTED,
43
+ font=("Segoe UI", 9, "bold"), anchor="w")
44
+
45
+ def _entry(self, parent, show=None, textvariable=None):
46
+ e = tk.Entry(parent, bg=FIELD, fg=FG, insertbackground=ACCENT,
47
+ relief="flat", font=("Segoe UI", 11), show=show or "",
48
+ highlightthickness=1, highlightbackground=BORDER,
49
+ highlightcolor=ACCENT, textvariable=textvariable)
50
+ e.configure(bd=6)
51
+ return e
52
+
53
+ def _build(self):
54
+ pad = dict(padx=22)
55
+ # header
56
+ head = tk.Frame(self, bg=BG)
57
+ head.pack(fill="x", pady=(18, 6), **pad)
58
+ tk.Label(head, text="πŸ”‘ VW Keystroke", bg=BG, fg=FG,
59
+ font=("Segoe UI", 17, "bold")).pack(side="left")
60
+ self.status_pill = tk.Label(head, text="…", bg=BG, fg=MUTED,
61
+ font=("Segoe UI", 9, "bold"))
62
+ self.status_pill.pack(side="right", pady=(6, 0))
63
+ tk.Label(self, text="Paste a secret β†’ local env file + your vault.",
64
+ bg=BG, fg=MUTED, font=("Segoe UI", 10)).pack(anchor="w", **pad)
65
+
66
+ card = tk.Frame(self, bg=CARD, highlightthickness=1,
67
+ highlightbackground=BORDER)
68
+ card.pack(fill="both", expand=True, padx=18, pady=14)
69
+ inner = tk.Frame(card, bg=CARD)
70
+ inner.pack(fill="both", expand=True, padx=18, pady=16)
71
+
72
+ # env var
73
+ self._label(inner, "ENV VARIABLE NAME").pack(fill="x")
74
+ self.var_e = self._entry(inner)
75
+ self.var_e.pack(fill="x", pady=(3, 12))
76
+
77
+ # project (combobox of existing + free text)
78
+ self._label(inner, "PROJECT β†’ ~/.config/<project>.env").pack(fill="x")
79
+ edir = core.env_dir(self.conf)
80
+ projects = core.list_projects(edir)
81
+ self.proj_v = tk.StringVar(value=core.default_project(self.conf))
82
+ self.option_add("*TCombobox*Listbox.background", FIELD)
83
+ self.option_add("*TCombobox*Listbox.foreground", FG)
84
+ self.option_add("*TCombobox*Listbox.selectBackground", ACCENT)
85
+ style = ttk.Style(self)
86
+ try:
87
+ style.theme_use("clam")
88
+ except Exception:
89
+ pass
90
+ style.configure("VW.TCombobox", fieldbackground=FIELD, background=FIELD,
91
+ foreground=FG, arrowcolor=MUTED, bordercolor=BORDER,
92
+ lightcolor=BORDER, darkcolor=BORDER)
93
+ self.proj_e = ttk.Combobox(inner, textvariable=self.proj_v, values=projects,
94
+ style="VW.TCombobox", font=("Segoe UI", 11))
95
+ self.proj_e.pack(fill="x", pady=(3, 12), ipady=3)
96
+
97
+ # vault item name (optional)
98
+ self._label(inner, "VAULT ITEM NAME (blank = same as variable)").pack(fill="x")
99
+ self.name_e = self._entry(inner)
100
+ self.name_e.pack(fill="x", pady=(3, 12))
101
+
102
+ # secret
103
+ self._label(inner, "SECRET VALUE").pack(fill="x")
104
+ row = tk.Frame(inner, bg=CARD)
105
+ row.pack(fill="x", pady=(3, 6))
106
+ self.secret_e = self._entry(row, show="β€’")
107
+ self.secret_e.pack(side="left", fill="x", expand=True)
108
+ self.show_v = tk.IntVar(value=0)
109
+ tk.Checkbutton(row, text="show", variable=self.show_v, command=self._toggle_show,
110
+ bg=CARD, fg=MUTED, selectcolor=FIELD, activebackground=CARD,
111
+ activeforeground=FG, font=("Segoe UI", 9), bd=0,
112
+ highlightthickness=0).pack(side="left", padx=(8, 0))
113
+
114
+ # also-to-vault toggle
115
+ self.vault_v = tk.IntVar(value=1)
116
+ tk.Checkbutton(inner, text="Also save to Vaultwarden", variable=self.vault_v,
117
+ bg=CARD, fg=FG, selectcolor=FIELD, activebackground=CARD,
118
+ activeforeground=FG, font=("Segoe UI", 10), bd=0,
119
+ highlightthickness=0).pack(anchor="w", pady=(6, 10))
120
+
121
+ # buttons
122
+ btns = tk.Frame(inner, bg=CARD)
123
+ btns.pack(fill="x")
124
+ self.drop_b = tk.Button(btns, text="Drop it ⏎", command=self.on_drop,
125
+ bg=ACCENT, fg="#062a17", activebackground=ACCENT_HI,
126
+ activeforeground="#062a17", relief="flat",
127
+ font=("Segoe UI", 11, "bold"), bd=0, padx=16, pady=8,
128
+ cursor="hand2")
129
+ self.drop_b.pack(side="left")
130
+ tk.Button(btns, text="Lock", command=self.on_lock, bg=CARD, fg=MUTED,
131
+ activebackground=CARD, activeforeground=DANGER, relief="flat",
132
+ font=("Segoe UI", 10), bd=0, padx=10, pady=8, cursor="hand2"
133
+ ).pack(side="right")
134
+
135
+ # status line
136
+ self.status = tk.Label(self, text="", bg=BG, fg=MUTED, font=("Segoe UI", 10),
137
+ wraplength=420, justify="left", anchor="w")
138
+ self.status.pack(fill="x", padx=22, pady=(0, 16))
139
+
140
+ self.bind("<Return>", lambda e: self.on_drop())
141
+ self.var_e.focus_set()
142
+
143
+ def _toggle_show(self):
144
+ self.secret_e.configure(show="" if self.show_v.get() else "β€’")
145
+
146
+ def _set_status(self, text, color=MUTED):
147
+ self.status.configure(text=text, fg=color)
148
+
149
+ def _refresh_status(self):
150
+ def work():
151
+ try:
152
+ st = core.session_state(self.conf)
153
+ except Exception:
154
+ st = "login"
155
+ self.after(0, lambda: self._show_pill(st))
156
+ threading.Thread(target=work, daemon=True).start()
157
+
158
+ def _show_pill(self, st):
159
+ m = {"ready": ("πŸ”“ unlocked", ACCENT), "apikey": ("πŸ”‘ api-key", ACCENT),
160
+ "locked": ("πŸ”’ locked", MUTED), "login": ("⚠ needs login", DANGER)}
161
+ text, color = m.get(st, ("…", MUTED))
162
+ self.status_pill.configure(text=text, fg=color)
163
+
164
+ # ---------- actions ----------
165
+ def on_lock(self):
166
+ core.lock()
167
+ self._set_status("Vault locked; cached session cleared.", MUTED)
168
+ self._refresh_status()
169
+
170
+ def on_drop(self):
171
+ if self._busy:
172
+ return
173
+ var = self.var_e.get().strip()
174
+ if not core.valid_var(var):
175
+ self._set_status("Enter a valid ENV variable name (letters, digits, _).", DANGER)
176
+ return
177
+ secret = self.secret_e.get()
178
+ if not secret:
179
+ self._set_status("Paste a secret value first.", DANGER)
180
+ return
181
+ project = (self.proj_v.get().strip() or core.default_project(self.conf))
182
+ name = self.name_e.get().strip() or None
183
+ no_vault = not self.vault_v.get()
184
+
185
+ mp = None
186
+ if not no_vault:
187
+ state = core.session_state(self.conf)
188
+ if state == "login":
189
+ messagebox.showinfo(
190
+ "One-time login",
191
+ "Not logged in yet. Run this once in a terminal:\n\n bw login\n\n"
192
+ "(email + master password + 2FA). Then come back and Drop it.")
193
+ return
194
+ if state == "locked":
195
+ mp = simpledialog.askstring("Unlock vault", "Vault master password:",
196
+ show="β€’", parent=self)
197
+ if not mp:
198
+ return
199
+
200
+ self._busy = True
201
+ self.drop_b.configure(state="disabled", text="Working…")
202
+ self._set_status("Working…", MUTED)
203
+
204
+ def work():
205
+ try:
206
+ res = core.drop(var, secret, project=project, name=name,
207
+ no_vault=no_vault, master_password=mp, conf=self.conf)
208
+ self.after(0, lambda: self._done(res))
209
+ except core.NeedUnlock:
210
+ self.after(0, lambda: self._fail("Wrong or missing master password."))
211
+ except core.NeedLogin as e:
212
+ self.after(0, lambda: self._fail(str(e)))
213
+ except core.VwksError as e:
214
+ self.after(0, lambda: self._fail(str(e)))
215
+ except Exception as e: # pragma: no cover
216
+ self.after(0, lambda: self._fail(f"Unexpected error: {e}"))
217
+
218
+ threading.Thread(target=work, daemon=True).start()
219
+
220
+ def _done(self, res):
221
+ self._busy = False
222
+ self.drop_b.configure(state="normal", text="Drop it ⏎")
223
+ msg = f"βœ“ {res['env_action']} in {res['env_path']}"
224
+ if res["vault"]:
225
+ msg += f"\nβœ“ vault: {res['vault']} in β€œ{res['folder']}”"
226
+ self._set_status(msg, ACCENT)
227
+ self.secret_e.delete(0, "end")
228
+ self.var_e.delete(0, "end")
229
+ self.show_v.set(0)
230
+ self._toggle_show()
231
+ # refresh project dropdown + pill
232
+ self.proj_e.configure(values=core.list_projects(core.env_dir(self.conf)))
233
+ self.var_e.focus_set()
234
+ self._refresh_status()
235
+
236
+ def _fail(self, text):
237
+ self._busy = False
238
+ self.drop_b.configure(state="normal", text="Drop it ⏎")
239
+ self._set_status("βœ— " + text, DANGER)
240
+ self._refresh_status()
241
+
242
+
243
+ def main():
244
+ try:
245
+ app = App()
246
+ except tk.TclError as e:
247
+ raise SystemExit(f"VW Keystroke GUI needs a display: {e}")
248
+ app.mainloop()
249
+
250
+
251
+ if __name__ == "__main__":
252
+ main()