├── CHANGELOG ├── LICENSE ├── Readme.md ├── cpass.cfg ├── cpass.py └── setup.py /CHANGELOG: -------------------------------------------------------------------------------- 1 | 0.9.4 2022-03-22 2 | 3 | Some fixes quite some time ago. 4 | 5 | Fixed: 6 | 7 | - Fix not able to click to focus first element. 8 | - Prioritize showing current folder on the header over help message. 9 | - Deleting in empty directory doesn't crash now. 10 | 11 | 12 | 0.9.3 2021-08-28 13 | 14 | Fixed: 15 | 16 | - If the password is empty, copy will now copy the empty password line. 17 | - Show a friendly warning instead of an error with trackback information. #4 18 | 19 | 20 | 0.9.2 2021-07-02 21 | 22 | Small changes are introduced regarding copy, search and sort. 23 | 24 | Changed (small ones): 25 | 26 | - The search is now smartcase like in vim. 27 | - The list is sorted case-insensitively. 28 | - Searching is now considering the pattern to be space separated keywords. The 29 | search result requires all keywords are in the password name, naturally. 30 | - Copy will copy only the right hand side of colon ":" if exists. 31 | 32 | Fixed: 33 | 34 | - Check if pass directory exists. 35 | - Colors of focused items in light color schemes now have correct reverse effects. 36 | - Do not move cursor after inserting a new item if the path is absolute. 37 | - Prevent focus change when in editbox. 38 | 39 | 40 | 0.9.1 2021-06-28 41 | 42 | Bugs fix release. 43 | 44 | Fixed: 45 | 46 | - Preview not updating due to name duplicates, e.g., when switching between a 47 | folder item and a file item with the same name. 48 | - Folder's count not updating after the item within are added/removed. 49 | - Git commit messages flashing by after editing password. Now it has been 50 | worked around. 51 | - When creating multi-level filename, e.g. "foo/bar", instead of using that 52 | whole name as a single file, now shows directory hierarchy correctly. 53 | - Fix new item not showing if a dir with the same name exists. 54 | 55 | 56 | 0.9.0 2021-06-25 57 | 58 | Added: 59 | 60 | - Search within the current folder, i.e., current window. Key bindings are vim-like: 61 | / search forward. 62 | ? search backward. 63 | n goto next. 64 | N goto previous. 65 | 66 | Fixed: 67 | 68 | - Position indicator not updating when moving the cursor up and down. 69 | 70 | 71 | 0.8.2 2021-06-23 72 | 73 | `cpass` is uploaded to pypi! 74 | 75 | Fixed: 76 | 77 | - When copy fails (possibly due to no X11), cpass will crash. Its output also 78 | messes the UI. Note after the fix the original error message is also blocked. 79 | 80 | 81 | 0.8.1 2021-06-20 82 | 83 | Fixed: 84 | 85 | - Save the log file in system temporary folder, normally `/tmp` for Linux. 86 | It was saved to the current location where cpass is started. 87 | 88 | 89 | 0.8.0 2021-06-19 90 | 91 | Changed: 92 | 93 | - Renamed the executable to `cpass`, the extension was removed. This breaks 94 | compatibility in a way, but I did not start a new major version, since I have 95 | not promoted cpass to anyone yet so the impact is nearly zero. Nobody cares. 96 | 97 | 98 | 0.7.0 2021-06-16 99 | 100 | Added: 101 | 102 | - Copy password, any line or specific field. 103 | 104 | Fixed: 105 | 106 | - Correct the default keybindings. 107 | 108 | 109 | 0.6.1 2021-06-12 110 | 111 | Fixed: 112 | 113 | - Warn the deletion with full path. 114 | - Use `y` to copy, which is the actual initially planned key binding. 115 | 116 | 117 | 0.6.0 2021-06-07 118 | 119 | Added: 120 | 121 | - Key binding customization support in the configuration file. 122 | 123 | Fixed: 124 | 125 | - Warning not shown for different password input. 126 | - Crash if there is no colors section in the configuration file. 127 | 128 | 129 | 0.5.0 2021-06-07 130 | 131 | Added: 132 | 133 | - Deleting passwords/folders with `pass rm -r`. 134 | 135 | Fixed: 136 | 137 | - Previous messages might persist after unfocusing the editbox. 138 | - Enter key not working on folders. 139 | 140 | 141 | 0.4.1 2021-06-03 142 | 143 | Added: 144 | 145 | - Focus the new/changed item after insert or generate. 146 | 147 | Fixed: 148 | 149 | - Update password preview after edit, insert or generate. 150 | 151 | 152 | 0.4.0 2021-06-02 153 | 154 | Changed: 155 | 156 | - Configuration option 'preview' is changed to 'preview_layout'. 157 | - Keybinding changes: only use 'i' for insert and only 'a' for generate. 158 | 159 | Added: 160 | 161 | - Generate password by `pass generate`. 162 | 163 | Fixed: 164 | 165 | - The UI actually updates with password operations like insert, generate, etc. 166 | 167 | 168 | 0.3.1 2021-05-30 169 | 170 | Hot fix release. 171 | 172 | Fixed: 173 | 174 | - Wrong folder preview due to duplicated name as files. 175 | - forgot to add a line of code in a previous commit. 176 | 177 | 178 | 0.3.0 2021-05-30 179 | 180 | Added: 181 | 182 | - Add password by `pass insert`. 183 | - Show count of folder contents. 184 | - Show main keybindings on the header. 185 | 186 | Fixed: 187 | 188 | - File name over-striped due to misuse of `rstrip` function. 189 | - Keybindings take over text editing. 190 | 191 | 192 | 0.2.2 - 2021-05-28 193 | 194 | Added: 195 | 196 | - Edit password by `pass edit`. 197 | 198 | 199 | 0.2.1 - 2021-05-24 200 | 201 | Hot fix release. 202 | 203 | Fixed: 204 | 205 | - Search for configuration file in user configuration folder instead of a relative path. 206 | 207 | 208 | 0.2.0 - 2021-05-24 209 | 210 | Added: 211 | 212 | - Configuration file support. 213 | - Able to toggle preview. 214 | 215 | 216 | 0.1.0 - 2021-05-23 217 | 218 | Initial release. 219 | 220 | Added: 221 | 222 | - Able to browse the password store. 223 | - Navigation with vim-like key mappings and mouse support. 224 | - Remembers the cursor position in every folder. 225 | - Preview passwords. 226 | - Show current directory and file/subfolder counts in top or bottom bar. 227 | - Some operations are dummy, they don't work. 228 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Lu Xu 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. 22 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # cpass: another console UI for pass 2 | 3 | **!DISCLAIMER!**: This is still WIP. Before the version 1.0.0, I do not guarantee that this program will run mostly as intended (doesn't mean 1.0.0 will be perfect, either). 4 | **Version 1.0.0 might break some of the backward compatibility**. 5 | 6 | It is recommended to back up your passwords or use git (by `pass git init`) to manage the password store. 7 | 8 | --- 9 | 10 | `cpass` is an [urwid](http://urwid.org/) based terminal user interface (TUI) for the standard unix password manager, [pass](https://www.passwordstore.org/). 11 | 12 | `cpass` tries to achieve a minimal, clean interface and utilizes vim-like keybinding. Also, thanks to the urwid module, mouse is supported quite well. 13 | 14 | ## Features: 15 | 16 | - Browse the local password store 17 | - Preview folders and passwords 18 | - Customizable: colors, key bindings and more 19 | - Pass operations: 20 | - add 21 | - edit 22 | - generate 23 | - remove 24 | - Copy passwords in various ways (also customizable) 25 | - Search passwords in the current directory 26 | 27 | Todo list: 28 | 29 | - More pass operations, e.g., find, copy, move, rename, git, otp 30 | - CLI arguments for more use cases, e.g. open in find mode, and close on copy. 31 | 32 | ## Requirement 33 | 34 | - [pass](https://www.passwordstore.org/) 35 | - [urwid](http://urwid.org/) python module 36 | - [xclip](https://github.com/astrand/xclip) for copying passwords 37 | 38 | Make sure you are using a local password store created/compatible with [`pass`](https://www.passwordstore.org/), which `cpass` will look for in `$PASSWORD_STORE_DIR`, otherwise in `~/.password_store/`. 39 | 40 | `pass` is required, although theoretically a `pass` compatible client does not need `pass` (e.g., [qtpass](https://qtpass.org/) can work with `git` and `gpg`). 41 | However, `pass` does a lot of things to assure the robustness and security of password management, there is no need to reinvent the wheels. 42 | 43 | ## Install 44 | 45 | - As python package: 46 | ``` 47 | pip install --user cpass 48 | ``` 49 | 50 | - Install with GNU Guix 51 | 52 | ``` 53 | guix pull 54 | guix install cpass 55 | ``` 56 | 57 | - Clone the repo or download the single script file. 58 | 59 | ## Usage: 60 | 61 | ### Start `cpass` 62 | 63 | For now, just run `cpass`. 64 | 65 | Some CLI arguments are in mind, but those are for future versions. 66 | 67 | ### Keybindings 68 | 69 | Basic navigation keybindings just work as in a lot of command line programs (like `less`): 70 | 71 | `h`, `j`, `k`, `l`, `g`, `G`, `ctrl+d`, `ctrl+u`, `ctrl+f`, `ctrl+b`, `ctrl+n`, `ctrl+p` 72 | 73 | For `pass` related operations: 74 | - `i` add a new password in current directory 75 | - `a` generate a new password in current directory 76 | - `d` delete current password file or directory after user confirms 77 | - `e` edit current password in `$EDITOR` 78 | - `z` toggle preview 79 | - `y` + `y/a/[0-9]` copy contents in password ('0' to copy the 10th line) 80 | - `/` or `?` will start a search (forward/backward) 81 | - `n` or `N` go to next or previous search result 82 | 83 | To-do ones (might change) 84 | 85 | - `I` to add multi-line password 86 | - `A` to generate with more options 87 | - `r` rename the file 88 | - `D`, `Y`, `P` remove, copy and paste files 89 | 90 | ### Mouse 91 | 92 | This is very intuitive. 93 | 94 | - Scroll to navigate up and down in the current list. 95 | - Left-click on the highlighted item will open it, otherwise will highlight it. 96 | - Right-click will go to the parent folder. 97 | 98 | ## Configuration 99 | 100 | Configuration file: `$XDG_CONFIG_DIR/cpass/cpass.cfg`, which falls back to `$HOME/.config/cpass/cpass.cfg` if not found. 101 | 102 | Example configuration file [cpass.cfg](cpass.cfg) has all available options set to the default value, with detailed comments. 103 | 104 | This is an overview of what can be customized through the configuration file, for the complete list of options, see [cpass.cfg](cpass.cfg): 105 | ``` 106 | [ui] 107 | preview_layout = side/bottom/horizontal/vertical 108 | 109 | [pass] 110 | no_symbols = true/false 111 | 112 | [keys] 113 | down = j, down, ctrl n 114 | up = k, up, ctrl p 115 | 116 | [copy_fields] 117 | login = l 118 | 119 | [color] 120 | normal = default, default 121 | dir = light blue, default 122 | 123 | [icon] 124 | dir = "󰉋 " 125 | file = "󰈤 " 126 | ``` 127 | 128 | Two sections, the `keys` and `color`, need some references: 129 | - Key bindings in `keys` section: 130 | - For all actions available to bind, see the example configuration file. 131 | - For the format to specify keys, see the [urwid documentation](http://urwid.org/manual/userinput.html#keyboard-input). 132 | - Colors in `color` section. The configuration is similar to (can be seen as) an urwid pallete consisting of multiple display attributes. In the example [cpass.cfg](cpass.cfg) I provided enough information to get started. If you want to know more: 133 | - See urwid documentation for [definition of a pallete](http://urwid.org/reference/display_modules.html#urwid.BaseScreen.register_palette_entry) and [a palette example](http://urwid.org/manual/displaymodules.html#setting-a-palette). 134 | - Also refer to documentation of the [available color names](http://urwid.org/reference/constants.html#foreground-and-background-colors) and general information on [display attributes](http://urwid.org/manual/displayattributes.html). 135 | 136 | ## Screenshot 137 | 138 | https://user-images.githubusercontent.com/12032219/123406878-f338b280-d5dd-11eb-951e-2a4fc185a65d.mp4 139 | -------------------------------------------------------------------------------- /cpass.cfg: -------------------------------------------------------------------------------- 1 | # The configuration file for cpass, the TUI for pass, the standard unix password manager 2 | # Author: Lu Xu 3 | # License: MIT License Copyright (c) 2021 Lu Xu 4 | 5 | # This example configuration file list all the available options and their 6 | # default values. That means commenting out (with `#') any or all of the 7 | # options of even remove this file won't change the default behaviour. 8 | 9 | [ui] 10 | # Layout of preview window, which shows content of the current highlighted 11 | # folder or decrypted password. 12 | # side or horizontal: the preview is split to right 13 | # bottom or vertical: the preview is split to bottom 14 | preview_layout = side 15 | 16 | [pass] 17 | # Pass related options, the default values below are also pass's defaults. 18 | # 19 | # Since pass command can be customized with a lot of environment variables, 20 | # the options down here are only those that can not be customized otherwise. 21 | # 22 | # Whether to use --no-symbols option in `pass generate`, true or false. 23 | no_symbols = false 24 | 25 | [keys] 26 | # Key bindings configuration. Each action can be assigned with multiple keys or 27 | # key combinations, for the key format, see 28 | # http://urwid.org/manual/userinput.html#keyboard-input 29 | # 30 | # Each key can be assigned to only one action, the last one override the 31 | # previous ones. (This actually depends on how configparser module works) 32 | # 33 | # Down here lists all defined actions and their default key bindings: 34 | dir_down = l, right 35 | dir_up = h, left 36 | down = j, down, ctrl n 37 | up = k, up, ctrl p 38 | down_screen = page down, ctrl f 39 | up_screen = page up, ctrl b 40 | down_half_screen = ctrl d 41 | up_half_screen = ctrl u 42 | end = G, end 43 | home = g, home 44 | cancel = esc 45 | confirm = enter 46 | search = / 47 | search_back = ? 48 | search_next = n 49 | search_prev = N 50 | insert = i 51 | generate = a 52 | edit = e 53 | delete = d 54 | copy = y 55 | toggle_preview = z 56 | quit = q 57 | 58 | [copy_fields] 59 | # Specify which key can be used to copy a specific field. The key will be pressed 60 | # after the 'copy' key in [keys] section, which defaults to 'y'. 61 | # 62 | # The key-value pair corresponding to the field and keybinding, respectively. 63 | # The field string is customizable. It can be any string that appears before a 64 | # colon ':' in the password content. 65 | # 66 | # E.g. if there is a line in the password content as 67 | # email: foo@bar.com 68 | # then you can copy the email address after the colon with 'y' + 'm' if you have 69 | # email = m 70 | # in this section. 71 | login = l 72 | 73 | [color] 74 | # The configuration of a color palette item is ([] means optional): 75 | # name = fg, bg[, mono[, fg_high, bg_high]] 76 | # The colors/attributes are the same as those in an urwid palette: 77 | # (name, fg, bg[, mono[, fg_high, bg_high]]), 78 | # 79 | # For definition, see 80 | # http://urwid.org/reference/display_modules.html#urwid.BaseScreen.register_palette_entry 81 | # For an palette example, see 82 | # http://urwid.org/manual/displaymodules.html#setting-a-palette 83 | # For available colors, see 84 | # http://urwid.org/reference/constants.html#foreground-and-background-colors 85 | # 86 | # For convenience, the 16 named colors are: 87 | # Standard background and foreground colors 88 | # black, dark red, dark green, brown, dark blue, dark magenta, dark cyan, light gray 89 | # Standard foreground colors (not safe to use as background) 90 | # dark gray, light red, light green, yellow, light blue, light magenta, light cyan, white 91 | # 92 | # Note: 93 | # The palette attributes *should* support comma separated color and settings: 94 | # foreground = red, bold, underline 95 | # But, since the comma is already used to seperate fg, bg and so on, it cannot 96 | # be used within any one of those attributes, otherwise the commas will be 97 | # confusing. 98 | # This then limits what can be customized for the colors. I decided to leave it 99 | # instead of trying to support multiple settings, a single color or setting is 100 | # enough. 101 | normal = default, default 102 | border = light green, default 103 | dir = light blue, default 104 | alert = light red, default 105 | bright = white, default 106 | focus = standout, default 107 | focusdir = black, light blue, bold 108 | 109 | [icon] 110 | # Icons in front of the file/folder name, similar to those in ranger or lf. 111 | dir = "/" 112 | file = " " 113 | -------------------------------------------------------------------------------- /cpass.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # Author: Lu Xu 3 | # License: MIT License Copyright (c) 2021 Lu Xu 4 | 5 | import os 6 | import re 7 | import urwid 8 | import logging 9 | import tempfile 10 | import configparser 11 | from subprocess import run, PIPE, DEVNULL 12 | 13 | version = "0.9.4" 14 | 15 | 16 | class PassNode(urwid.AttrMap): 17 | def __init__(self, node, root, isdir=False): 18 | self.empty = node is None 19 | self.isdir = isdir 20 | self.node = node or "-- EMPTY --" 21 | self.path = os.path.join(root, node) if node else '' 22 | self.icon = config.icon_dir if isdir else config.icon_file if node else '' 23 | 24 | self._selectable = True 25 | super().__init__(urwid.Columns([ 26 | ('pack', urwid.Text(self.icon)), 27 | urwid.Text(self.node, wrap='clip'), 28 | ('pack', urwid.Text('')) 29 | ]), 30 | 'dir' if isdir else '' if node else 'bright', 31 | 'focusdir' if isdir else 'focus' if node else 'bright', 32 | ) 33 | 34 | self.update_count() 35 | 36 | def update_count(self): 37 | # 'topdown' option in os.walk makes this possible (see Pass.extract_all), 38 | # so that children folders are traversed before its parent (the 'len' 39 | # function below can be executed). 40 | if self.isdir: 41 | count = len(Pass.all_pass[self.path]) 42 | self.original_widget.contents[2][0].set_text(str(count)) 43 | 44 | def keypress(self, size, key): 45 | """ let the widget pass through the keys to parent widget """ 46 | return key 47 | 48 | 49 | class PassList(urwid.ListBox): 50 | def __init__(self, body, root='', ui=None): 51 | self._ui = ui 52 | self.root = root 53 | self._size = (1, 1) 54 | super().__init__(body) 55 | 56 | def mouse_event(self, size, event, button, col, row, focus): 57 | self._size = size 58 | focus_offset = self.get_focus_offset_inset(size)[0] 59 | 60 | logging.debug("passlist mouse event: {} {} {} {} {} {} {} {}".format( 61 | size, event, button, col, row, focus, self.focus_position, focus_offset 62 | )) 63 | 64 | if button == 1: 65 | if size[1] > len(self.body): 66 | # NOTE: offset is wrong(?) when size is larger than length 67 | # so the processing is different 68 | if row == self.focus_position: 69 | self.dir_navigate('down') 70 | else: 71 | self.list_navigate(new_focus=row) 72 | else: 73 | if row == focus_offset: 74 | self.dir_navigate('down') 75 | else: 76 | self.list_navigate(new_focus=self.focus_position - focus_offset + row) 77 | elif button == 3: 78 | self.dir_navigate('up') 79 | elif button == 4: 80 | self.list_navigate(-1) 81 | elif button == 5: 82 | self.list_navigate(1) 83 | else: 84 | return super().mouse_event(size, event, button, col, row, focus) 85 | 86 | def keypress(self, size, key): 87 | self._size = size 88 | logging.debug("passlist keypress: {} {}".format(key, size)) 89 | 90 | list_navigation_offsets = { 91 | 'down': 1, 92 | 'up': -1, 93 | # overshoot to go to bottom/top 94 | 'end': len(self.body), 95 | 'home': -len(self.body), 96 | 'down_screen': size[1], 97 | 'up_screen': -size[1], 98 | 'down_half_screen': size[1] // 2, 99 | 'up_half_screen': -size[1] // 2, 100 | } 101 | 102 | dir_navigation_directions = { 103 | # the confirm key doubles as enter folder key 104 | 'confirm': 'down', 105 | 'dir_down': 'down', 106 | 'dir_up': 'up', 107 | } 108 | 109 | action = config.keybindings.get(key) 110 | if action in list_navigation_offsets: 111 | self.list_navigate(list_navigation_offsets[action]) 112 | elif action in dir_navigation_directions: 113 | self.dir_navigate(dir_navigation_directions[action]) 114 | else: 115 | return super().keypress(size, key) 116 | 117 | def dir_navigate(self, direction): 118 | # record current position 119 | Pass.all_pass[self.root].pos = self.focus_position 120 | 121 | # change root position accordingly 122 | if direction in 'down' and self.focus.isdir: 123 | self.root = os.path.join(self.root, self.focus.node) 124 | elif direction in 'up': 125 | self.root = os.path.dirname(self.root) 126 | 127 | # update listbox content, this way the list itself is not replaced 128 | self.body[:] = Pass.all_pass[self.root] 129 | 130 | # restore cursor position of the new root 131 | self.focus_position = Pass.all_pass[self.root].pos 132 | 133 | self._ui.update_view() 134 | 135 | def list_navigate(self, shift=0, new_focus=None): 136 | """ either specify a shift offset, or an absolute target position """ 137 | offset = self.get_focus_offset_inset(self._size)[0] 138 | 139 | if new_focus is not None: 140 | shift = new_focus - self.focus_position 141 | else: 142 | new_focus = shift + self.focus_position 143 | new_offset = offset + shift 144 | 145 | # border check 146 | new_focus = min(max(0, new_focus), len(self.body) - 1) 147 | new_offset = min(max(0, new_offset), self._size[1] - 1) 148 | 149 | self.change_focus(self._size, new_focus, offset_inset=new_offset) 150 | self._ui.update_view() 151 | 152 | def insert(self, node): 153 | def insert_relative(r, n): 154 | # if starts with /, then assume the node is relative to store root 155 | if n.startswith('/'): 156 | n = n.lstrip('/') 157 | r = '' 158 | 159 | # separate at the first / 160 | n1, sep, n2 = n.partition('/') 161 | # recursively insert if there are more levels 162 | if sep == '/': 163 | insert_relative(os.path.join(r, n1), n2) 164 | 165 | # change stored list 166 | passnode = PassNode(n1, r, sep == '/') 167 | if Pass.all_pass.get(r) is None: 168 | Pass.all_pass[r] = FolderWalker(r) 169 | Pass.all_pass[r].pos = Pass.all_pass[r].insert(passnode) 170 | 171 | # do not change cursor position if the path is not relative 172 | return Pass.all_pass[r].pos if r == self.root else None 173 | 174 | inserted_pos = insert_relative(self.root, node.strip()) 175 | # change listwalker 176 | self.body[:] = Pass.all_pass[self.root] 177 | # focus the new node 178 | self.list_navigate(new_focus=inserted_pos) 179 | 180 | self._ui.update_view() 181 | 182 | def delete(self, pos): 183 | # change stored list 184 | Pass.all_pass[self.root].pop(pos) 185 | # change listwalker 186 | self.body[:] = Pass.all_pass[self.root] 187 | 188 | self._ui.update_view() 189 | 190 | # TODO: this seems odd being here 191 | def update_root_count(self): 192 | for n in Pass.all_pass[os.path.dirname(self.root)]: 193 | if n.node == self.root and n.isdir: 194 | n.update_count() 195 | return 196 | 197 | 198 | class FolderWalker(list): 199 | """ 200 | Customize list operations, mainly 201 | - keep a placeholder item in empty list and 202 | - keep items sorted 203 | """ 204 | def __init__(self, root, dirs=[], files=[]): 205 | self.pos = 0 # cursor position 206 | 207 | self[:] = [PassNode(f, root, True) for f in sorted(dirs, key=str.lower)] + \ 208 | [PassNode(f, root) for f in sorted(files, key=str.lower)] 209 | 210 | # prevent empty list, which troubles listbox operations 211 | if len(self) == 0: 212 | super().append(PassNode(None, None)) 213 | 214 | def pop(self, index=-1): 215 | super().pop(index) 216 | 217 | # add a empty placeholder 218 | if len(self) == 0: 219 | super().append(PassNode(None, None)) 220 | 221 | def insert(self, node): 222 | # if node already exist, return the index 223 | for n in self: 224 | if n.node == node.node and n.isdir == node.isdir: 225 | return self.index(n) 226 | 227 | # pop the empty placeholder node beforehand 228 | if len(self) == 1 and self[0].empty: 229 | super().pop() 230 | 231 | # insert and sort, with directories sorted before files 232 | super().insert(self.pos, node) 233 | self[:] = sorted([n for n in self if n.isdir], key=lambda n: n.node.lower()) + \ 234 | sorted([n for n in self if not n.isdir], key=lambda n: n.node.lower()) 235 | return self.index(node) 236 | 237 | 238 | # TODO: auto change split direction based on terminal size 239 | # TODO: multiline insert, this should be easy since we have the workaround in Pass.edit 240 | # TODO: mv, cp support 241 | # TODO: QR code generate, maybe? 242 | # TODO: background preview, or/and cache preview results 243 | # TODO: CLI arguments 244 | # TODO: git support 245 | # TODO: otp support 246 | class UI(urwid.Frame): 247 | def __init__(self): 248 | self._app_string = 'cPass' 249 | self._help_string = ' a:generate d:delete e:edit i:insert y:copy z:toggle /:search' 250 | self._edit_type = None 251 | self._last_preview = None 252 | self._preview_shown = True 253 | self._search_pattern = None 254 | self._search_direction = 1 255 | 256 | # header 257 | self.header_prefix = urwid.Text(('border', '{}:'.format(self._app_string))) 258 | self.path_indicator = urwid.Text(('bright', ''), wrap='clip') 259 | self.help_text = urwid.Text(self._help_string, wrap='clip', align='right') 260 | # priority on showing full path 261 | self.header_widget = urwid.Columns([ 262 | ('pack', self.header_prefix), 263 | ('pack', self.path_indicator), 264 | self.help_text 265 | ], dividechars=1) 266 | 267 | # footer 268 | self.messagebox = urwid.Text('') 269 | self.count_indicator = urwid.Text('', align='right') 270 | self.footer_widget = urwid.Columns([ 271 | self.messagebox, 272 | ('pack', urwid.AttrMap(self.count_indicator, 'border')) 273 | ]) 274 | 275 | # some dynamic widgets 276 | self.divider = urwid.AttrMap(urwid.Divider('-'), 'border') 277 | self.preview = urwid.Filler(urwid.Text(''), valign='top') 278 | self.editbox = urwid.Edit() 279 | 280 | self.walker = urwid.SimpleListWalker(Pass.all_pass['']) 281 | self.listbox = PassList(self.walker, ui=self) 282 | 283 | # use Columns for horizonal layout, and Pile for vertical 284 | if config.preview_layout in ['side', 'horizontal']: 285 | self.middle = urwid.Columns([], dividechars=1) 286 | elif config.preview_layout in ['bottom', 'vertical']: 287 | self.middle = urwid.Pile([]) 288 | self.update_preview_layout() 289 | self.update_view() 290 | 291 | super().__init__(self.middle, self.header_widget, self.footer_widget) 292 | 293 | def message(self, message, alert=False): 294 | self.messagebox.set_text(('alert' if alert else 'normal', 295 | message.replace('\n', ' '))) 296 | 297 | def update_preview_layout(self): 298 | if self._preview_shown: 299 | if config.preview_layout in ['side', 'horizontal']: 300 | self.middle.contents = [(self.listbox, ('weight', 1, False)), 301 | (self.preview, ('weight', 1, False))] 302 | if config.preview_layout in ['bottom', 'vertical']: 303 | self.middle.contents = [(self.listbox, ('weight', 1)), 304 | (self.divider, ('pack', 1)), 305 | (self.preview, ('weight', 1))] 306 | self.update_preview() 307 | else: 308 | self.middle.contents = [(self.listbox, ('weight', 1, False))] 309 | self.middle.focus_position = 0 310 | 311 | def mouse_event(self, size, event, button, col, row, focus): 312 | logging.debug(f"ui mouse event: {size} {event} {button} {col} {row} {focus}") 313 | # Prevent focus change due to clicking when editing 314 | r = self.contents['footer'][0].rows(size[:1], True) 315 | if self._edit_type is None or self._edit_type and row >= size[1] - r: 316 | super().mouse_event(size, event, button, col, row, focus) 317 | 318 | def keypress(self, size, key): 319 | logging.debug("ui keypress: {} {}".format(key, size)) 320 | action = config.keybindings.get(key) 321 | if action == 'cancel': 322 | self.unfocus_edit() 323 | elif self._edit_type == "copy": 324 | self.unfocus_edit() 325 | self.copy_by_key(key) 326 | elif self._edit_type == "delete": 327 | self.unfocus_edit() 328 | self.delete_confirm(key) 329 | elif self._edit_type is not None: 330 | if action == 'confirm': 331 | self.handle_input() 332 | else: 333 | # pass through to edit widget (the focused widget) 334 | return super().keypress(size, key) 335 | elif action == 'quit': 336 | raise urwid.ExitMainLoop 337 | elif action == 'search' or action == 'search_back': 338 | self.focus_edit("search", '/' if action == 'search' else '?') 339 | self._search_direction = 1 if action == 'search' else -1 340 | elif action == 'search_next' or action == 'search_prev': 341 | self.search_in_dir(self._search_pattern, 342 | 1 if action == 'search_next' else -1) 343 | elif action == 'insert': 344 | self.focus_edit("insert", 'Enter password filename: ') 345 | elif action == 'generate': 346 | self.focus_edit("generate", 'Generate a password file: ') 347 | elif action == 'edit' and not self.listbox.focus.isdir: 348 | self.run_pass(Pass.edit, None, 349 | self.listbox.focus.node, self.listbox.root, "Edit: {}") 350 | urwid.emit_signal(self, 'redraw') 351 | elif action == 'delete' and not self.listbox.focus.empty: 352 | self.focus_edit("delete", 'Are you sure to delete {} {}? [Y/n]'.format( 353 | "the whole folder" if self.listbox.focus.isdir else "the file", 354 | os.path.join('/', self.listbox.root, self.listbox.focus.node) 355 | )) 356 | elif action == 'copy': 357 | self.copy_confirm() 358 | elif action == 'toggle_preview': 359 | self._preview_shown = not self._preview_shown 360 | self.update_preview_layout() 361 | else: 362 | return super().keypress(size, key) 363 | 364 | def unfocus_edit(self): 365 | self._edit_type = None 366 | self.contents['footer'] = (self.footer_widget, None) 367 | self.set_focus('body') 368 | self.messagebox.set_text('') 369 | self.editbox.set_mask(None) 370 | 371 | def focus_edit(self, edit_type, cap, mask=None): 372 | self._edit_type = edit_type 373 | self.contents['footer'] = (self.editbox, None) 374 | self.set_focus('footer') 375 | self.editbox.set_caption(cap) 376 | self.editbox.set_mask(mask) 377 | self.editbox.set_edit_text('') 378 | 379 | def handle_input(self): 380 | # these codes are ugly 381 | edit_type = self._edit_type 382 | self.unfocus_edit() 383 | if edit_type == "search": 384 | self._search_pattern = self.editbox.edit_text 385 | self.search_in_dir(self._search_pattern, 1) 386 | elif edit_type == "generate": 387 | self.run_pass(Pass.generate, self.listbox.insert, 388 | self.editbox.edit_text, self.listbox.root, "Generate: {}") 389 | self.listbox.update_root_count() 390 | elif edit_type == "insert": 391 | self._insert_node = self.editbox.edit_text 392 | self.focus_edit("insert_password", 'Enter password: ', mask='*') 393 | elif edit_type == "insert_password": 394 | self._insert_pass = self.editbox.edit_text 395 | self.focus_edit("insert_password_confirm", 'Enter password again: ', mask='*') 396 | elif edit_type == "insert_password_confirm": 397 | self._insert_pass_again = self.editbox.edit_text 398 | if self._insert_pass == self._insert_pass_again: 399 | self.run_pass(Pass.insert, self.listbox.insert, 400 | self._insert_node, self.listbox.root, "Insert: {}", 401 | args=(self._insert_pass,)) 402 | self.listbox.update_root_count() 403 | else: 404 | self.message("Password is not the same", alert=True) 405 | 406 | def update_view(self): 407 | # update header 408 | self.path_indicator.set_text(('bright', "/" + self.listbox.root)) 409 | 410 | # update footer 411 | self.count_indicator.set_text("{}/{}".format( 412 | self.listbox.focus_position + 1, 413 | len(self.listbox.body) 414 | ) if not self.listbox.focus.empty else "0/0") 415 | 416 | self.update_preview() 417 | 418 | def update_preview(self, force=False): 419 | if not self._preview_shown: 420 | return 421 | 422 | if not force and self.listbox.focus == self._last_preview: 423 | return 424 | self._last_preview = self.listbox.focus 425 | 426 | if not self.listbox.focus.empty: 427 | path = os.path.join(self.listbox.root, self.listbox.focus.node) 428 | if self.listbox.focus.isdir: 429 | preview = "\n".join([(f.icon + f.node) for f in Pass.all_pass[path]]) 430 | else: 431 | res = Pass.show(path) 432 | preview = res.stderr if res.returncode else res.stdout 433 | else: 434 | preview = "" 435 | 436 | self.preview.original_widget.set_text(preview) 437 | 438 | def run_pass(self, func, lfunc, node, root, msg='', args=(), largs=()): 439 | # do not accept password name ends with /, pass itself has problems 440 | if node.endswith('/'): 441 | self.message(f'Can not create a directory: {node}.', alert=True) 442 | return 443 | 444 | path = os.path.join(root, node) 445 | res = func(path, *args) 446 | if res.returncode == 0: 447 | self.message(msg.format(path) if func != Pass.show else '') 448 | if lfunc: 449 | lfunc(node if lfunc == self.listbox.insert else largs[0]) 450 | # some operations like generating password need updating the preview 451 | self.update_preview(True) 452 | else: 453 | self.message(res.stderr, alert=True) 454 | 455 | return res 456 | 457 | def delete_confirm(self, key): 458 | if key in ['y', 'Y', 'd', 'enter']: 459 | self.run_pass(Pass.delete, self.listbox.delete, 460 | self.listbox.focus.node, self.listbox.root, 461 | "Deleting {}", largs=(self.listbox.focus_position,)) 462 | # TODO: put this into lower level functions 463 | self.listbox.update_root_count() 464 | elif key in ['n', 'N']: 465 | self.message("Abort.") 466 | else: 467 | self.message("Invalid option.", alert=True) 468 | 469 | def parse_pass(self, passwd): 470 | # TODO: mark numbers on the side 471 | """ 472 | parse the decryped content of the password file 473 | and relate shortcut keys to the corrsponding texts 474 | """ 475 | lines = passwd.split('\n') 476 | # 1. default: yy to copy first line, ya to copy all lines 477 | copiable_fields = {'a': passwd, 'y': lines[0], '1': lines[0]} 478 | 479 | for i in range(1, len(lines)): 480 | field, sep, value = [s.strip() for s in lines[i].partition(':')] 481 | # 2. y[0-9] to copy that line, right of colon if applicable 482 | if i < 10: 483 | copiable_fields[str(i + 1)[-1]] = value if sep == ':' else field 484 | # 3. customized field shortcuts 485 | if sep == ':' and field in config.copy_bindings: 486 | copiable_fields[config.copy_bindings[field]] = value 487 | 488 | return copiable_fields 489 | 490 | def copy_confirm(self): 491 | if self.listbox.focus.isdir: 492 | return 493 | if self._preview_shown: 494 | password = self.preview.original_widget.text 495 | else: 496 | res = self.run_pass(Pass.show, None, self.listbox.focus.node, self.listbox.root) 497 | if res.returncode == 0: 498 | password = res.stdout 499 | else: 500 | return 501 | 502 | pw = self.parse_pass(password.rstrip('\n')) 503 | self.focus_edit("copy", 'Copy [{}]: '.format(''.join(sorted(pw)))) 504 | self._parsed_password = pw 505 | 506 | def copy_by_key(self, key): 507 | if key in self._parsed_password: 508 | copy_text = self._parsed_password[key] 509 | # stderr and stdout have to be dropped, otherwise the program is stuck 510 | res = run(['xclip', '-selection', Pass.X_SELECTION], 511 | text=True, input=copy_text, stderr=DEVNULL, stdout=DEVNULL) 512 | if res.returncode == 0: 513 | self.message("Copied.") 514 | else: 515 | self.message("Copy with xclip failed", alert=True) 516 | else: 517 | self.message("Nothing copied", alert=True) 518 | 519 | def search_in_dir(self, pattern, direction): 520 | """ direction = 1 or -1 to specify the search direction """ 521 | if pattern is None: 522 | self.message("No search pattern", alert=True) 523 | return 524 | 525 | # search from the next/previous, wrap if reaching bottom/top 526 | start = self.listbox.focus_position 527 | direction *= self._search_direction 528 | # list of indexes according to the start point and order 529 | indexes = list(range(len(self.listbox.body))) 530 | # the math here is kind of magic, it's the result after simplification 531 | search_list = (indexes[start+direction::direction] + 532 | indexes[:start+direction:direction]) 533 | 534 | for i in search_list: 535 | node = self.listbox.body[i].node 536 | # ignore case if all letters are lower case 537 | if pattern == pattern.lower(): 538 | node = node.lower() 539 | 540 | # search for all space separated words 541 | if all([s in node for s in pattern.split()]): 542 | self.listbox.list_navigate(new_focus=i) 543 | return 544 | 545 | self.message("No matching", alert=True) 546 | 547 | 548 | class Pass: 549 | FALLBACK_PASS_DIR = os.path.join(os.getenv("HOME"), ".password_store") 550 | PASS_DIR = os.getenv("PASSWORD_STORE_DIR", FALLBACK_PASS_DIR) 551 | X_SELECTION = os.getenv("PASSWORD_STORE_X_SELECTION", "clipboard") 552 | EDITOR = os.getenv("EDITOR", "vi") 553 | all_pass = dict() 554 | # exit if pass dir does not exit 555 | if not os.path.exists(PASS_DIR): 556 | print("'{}' or $PASSWORD_STORE_DIR does not exist".format(FALLBACK_PASS_DIR)) 557 | print("See `man pass` for how to set password storage directory.") 558 | exit() 559 | 560 | @classmethod 561 | def extract_all(cls): 562 | # pass files traversal, topdown option is essential, see PassNode 563 | for root, dirs, files in os.walk(cls.PASS_DIR, topdown=False): 564 | if not root.startswith(os.path.join(cls.PASS_DIR, '.git')): 565 | root = os.path.normpath(os.path.relpath(root, cls.PASS_DIR)).lstrip('.') 566 | dirs = [d for d in dirs if d != '.git'] 567 | files = [file[:-4] for file in files if file.endswith('.gpg')] 568 | # NOTE: all_pass, FolderWalker, PassNode references are in a cycle. 569 | cls.all_pass[root] = FolderWalker(root, dirs, files) 570 | 571 | @staticmethod 572 | def show(path): 573 | logging.debug("Showing password for {}".format(path)) 574 | return run(['pass', 'show', path], stdout=PIPE, stderr=PIPE, text=True) 575 | 576 | @classmethod 577 | def edit(cls, path): 578 | # work around terminal output by manually edit temp file and insert with multiline 579 | with tempfile.NamedTemporaryFile() as fp: 580 | res = cls.show(path) 581 | if res.returncode != 0: 582 | return res 583 | fp.write(res.stdout.encode()) 584 | fp.flush() 585 | # can not pipe stdout because editor won't show otherwise 586 | res = run([cls.EDITOR, fp.name], stderr=PIPE) 587 | if res.returncode != 0: 588 | return res 589 | fp.seek(0) 590 | password = fp.read() 591 | return run(['pass', 'insert', '-m', '-f', path], input=password, 592 | stderr=PIPE, stdout=PIPE) 593 | 594 | @staticmethod 595 | def insert(path, password): 596 | pw = password + '\n' + password + '\n' 597 | return run(['pass', 'insert', '-f', path], input=pw, 598 | stdout=PIPE, stderr=PIPE, text=True) 599 | 600 | @staticmethod 601 | def generate(path): 602 | command = ['pass', 'generate', '-f', path] 603 | if config.no_symbols: 604 | command.append('-n') 605 | return run(command, stdout=PIPE, stderr=PIPE, text=True) 606 | 607 | @staticmethod 608 | def delete(path): 609 | command = ['pass', 'rm', '-r', '-f', path] 610 | return run(command, stdout=PIPE, stderr=PIPE, text=True) 611 | 612 | 613 | class MyConfigParser(configparser.RawConfigParser): 614 | def __init__(self): 615 | super().__init__() 616 | 617 | DEFAULT_CONFIG_DIR = os.path.join(os.getenv("HOME"), ".config") 618 | CONFIG_DIR = os.getenv("XDG_CONFIG_DIR", DEFAULT_CONFIG_DIR) 619 | CONFIG = os.path.join(CONFIG_DIR, "cpass", "cpass.cfg") 620 | if os.path.exists(CONFIG): 621 | self.read(CONFIG) 622 | 623 | self.preview_layout = self.get('ui', 'preview_layout', 'side') 624 | self.icon_dir = self.get('icon', 'dir', '/') 625 | self.icon_file = self.get('icon', 'file', ' ') 626 | self.no_symbols = self.get('pass', 'no_symbols', 'false', boolean=True) 627 | 628 | self.keybindings = self.get_keybindings() 629 | self.palette = self.get_palette() 630 | self.copy_bindings = self.get_copybindings() 631 | 632 | def get(self, section, option, fallback=None, boolean=False): 633 | try: 634 | result = super().get(section, option) 635 | return result == 'true' if boolean else result.strip("\"\'") 636 | except (configparser.NoOptionError, configparser.NoSectionError): 637 | return fallback 638 | 639 | def get_keybindings(self): 640 | action_keys = { 641 | 'dir_down': ['l', 'right'], 642 | 'dir_up': ['h', 'left'], 643 | 'down': ['j', 'down', 'ctrl n'], 644 | 'up': ['k', 'up', 'ctrl p'], 645 | 'down_screen': ['page down', 'ctrl f'], 646 | 'up_screen': ['page up', 'ctrl b'], 647 | 'down_half_screen': ['ctrl d'], 648 | 'up_half_screen': ['ctrl u'], 649 | 'end': ['G', 'end'], 650 | 'home': ['g', 'home'], 651 | 'cancel': ['esc'], 652 | 'confirm': ['enter'], 653 | 'search': ['/'], 654 | 'search_back': ['?'], 655 | 'search_next': ['n'], 656 | 'search_prev': ['N'], 657 | 'insert': ['i'], 658 | 'generate': ['a'], 659 | 'edit': ['e'], 660 | 'delete': ['d'], 661 | 'copy': ['y'], 662 | 'toggle_preview': ['z'], 663 | 'quit': ['q'] 664 | } 665 | 666 | # map keys to actions, only one action can be mapped to one key 667 | keys = {} 668 | # default key bindings 669 | for action in action_keys: 670 | for key in action_keys[action]: 671 | keys[key] = action 672 | # update from configuration file 673 | if self.has_section('keys'): 674 | for action in self.options('keys'): 675 | for key in re.split(',\\s*', self.get('keys', action, '')): 676 | keys[key] = action 677 | 678 | return keys 679 | 680 | def get_palette(self): 681 | palettes = { 682 | # name fg bg mono 683 | 'normal': ('default', 'default'), 684 | 'border': ('light green', 'default'), 685 | 'dir': ('light blue', 'default'), 686 | 'alert': ('light red', 'default'), 687 | 'bright': ('white', 'default'), 688 | 'focus': ('standout', 'default'), 689 | 'focusdir': ('black', 'light blue', 'bold'), 690 | } 691 | 692 | # update from configuration file 693 | if self.has_section('color'): 694 | for name in self.options('color'): 695 | palettes[name] = re.split(',\\s*', self.get('color', name, '')) 696 | 697 | return [(name, *palettes[name]) for name in palettes] 698 | 699 | def get_copybindings(self): 700 | """ get field-key pairs """ 701 | copy_bindings = {'login': 'l'} 702 | 703 | if self.has_section('copy_fields'): 704 | for field in self.options('copy_fields'): 705 | copy_bindings[field] = self.get('copy_fields', field) 706 | 707 | return copy_bindings 708 | 709 | 710 | def main(): 711 | Pass.extract_all() 712 | passui = UI() 713 | 714 | mainloop = urwid.MainLoop(passui, palette=config.palette) 715 | # set no timeout after escape key 716 | mainloop.screen.set_input_timeouts(complete_wait=0) 717 | urwid.register_signal(UI, 'redraw') 718 | urwid.connect_signal(passui, 'redraw', mainloop.screen.clear) 719 | mainloop.run() 720 | 721 | 722 | logging.basicConfig(level=(logging.DEBUG if os.getenv('DEBUG') else logging.DEBUG), 723 | filename=os.path.join(tempfile.gettempdir(), 'cpass.log')) 724 | 725 | config = MyConfigParser() 726 | if __name__ == '__main__': 727 | main() 728 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | with open('Readme.md', 'r') as f: 4 | long_description = f.read() 5 | 6 | for line in open('cpass.py', 'r'): 7 | if line.startswith('version'): 8 | version = line.split('=')[1].strip(' \"\'\n') 9 | break 10 | 11 | setup( 12 | name='cpass', 13 | version=version, 14 | description='A TUI for pass, the standard Unix password manager', 15 | long_description=long_description, 16 | long_description_content_type="text/markdown", 17 | license='MIT', 18 | author='Lu Xu', 19 | author_email='oliver_lew@outlook.com', 20 | url='https://github.com/OliverLew/cpass', 21 | # Single-file module and a console script 22 | py_modules=['cpass'], 23 | entry_points={ 24 | 'console_scripts': [ 25 | 'cpass = cpass:main', 26 | ], 27 | }, 28 | python_requires='>=3', 29 | install_requires=['urwid'], 30 | data_files=[('share/doc/cpass', [ 31 | 'cpass.cfg', 32 | 'CHANGELOG', 33 | 'Readme.md', 34 | ])], 35 | classifiers=[ 36 | 'Development Status :: 3 - Alpha', 37 | 'License :: OSI Approved :: MIT License', 38 | 'Programming Language :: Python :: 3' 39 | ], 40 | ) 41 | --------------------------------------------------------------------------------