From 40e5d6592c1acbd7603bae225e9c3dd05754eba7 Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Wed, 19 Jan 2022 10:55:49 -0500 Subject: [PATCH 01/67] Upgrade zsh, and be more precise with where we apply black. --- .zshrc | 6 +++--- config/nvim/init.vim | 10 ++++++++-- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/.zshrc b/.zshrc index 042e613..45606d2 100644 --- a/.zshrc +++ b/.zshrc @@ -86,7 +86,7 @@ HYPHEN_INSENSITIVE="true" # Example format: plugins=(rails git textmate ruby lighthouse) # Add wisely, as too many plugins slow down shell startup. plugins=( - cargo + rust git tmux virtualenvwrapper @@ -133,7 +133,7 @@ alias open=xdg-open # To customize prompt, run `p10k configure` or edit ~/.p10k.zsh. [[ -f ~/.p10k.zsh ]] && source ~/.p10k.zsh -alias cat='echo "Try \`bat\` Instead"' +alias cat=bat export MCFLY_INTERFACE_VIEW=BOTTOM -export MCFLY_FUZZY=3 +export MCFLY_FUZZY=2 eval "$(mcfly init zsh)" diff --git a/config/nvim/init.vim b/config/nvim/init.vim index 26d3b6c..6c1a40a 100644 --- a/config/nvim/init.vim +++ b/config/nvim/init.vim @@ -9,6 +9,7 @@ let g:python3_host_prog = '/home/feanil/.virtualenvs/neovim/bin/python' let g:vim_isort_python_version = 'python3' let g:black_virtualenv = '/home/feanil/.virtualenvs/neovim' +let g:black_linelength = 99 call plug#begin('~/.local/share/nvim/plugged') @@ -54,8 +55,13 @@ Plug 'hashivim/vim-terraform' " Initialize plugin system call plug#end() -autocmd BufWritePre *.py execute ':Black' -autocmd BufWritePre *.py execute ':Isort' +" personal code should use black but forks should not. +autocmd BufWritePre ~/src/personal/*.py execute ':Black' +autocmd BufWritePre ~/src/personal/*.py execute ':Isort' + +" edX things are not using these yet but we should for hacking. +autocmd BufWritePre ~/work/src/hacking/*.py execute ':Black' +autocmd BufWritePre ~/work/src/hacking/*.py execute ':Isort' set number set ruler From 8d90aba825566da786997afa118a9befb149bba5 Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Thu, 20 Jan 2022 13:36:39 -0500 Subject: [PATCH 02/67] feat: Handle work and personal signatures on the same machine. Based on https://blog.hao.dev/how-to-use-different-git-emails-for-personal-and-work-repositories-on-the-same-machine --- README.md | 17 +++++++++++++++-- gitconfig.core | 40 ++++++++++++++++++++++++++++++++++++++++ gitconfig.edx | 18 ------------------ gitconfig.personal | 35 ----------------------------------- gitconfig.work | 3 +++ 5 files changed, 58 insertions(+), 55 deletions(-) create mode 100644 gitconfig.core delete mode 100644 gitconfig.edx create mode 100644 gitconfig.work diff --git a/README.md b/README.md index 63b948f..0b3c755 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,14 @@ +Git +=== +Install git +git clone git@github.com/feanil/dotfiles src/dotfiles +ln -sf src/dotfiles/gitconfig.core ~/.gitconfig + +Update as necessary for work and personal paths. + +VIM +=== + Install NeoVim Copy nvimrc to ~/.nvimrc Copy nvim directory to ~/.nvim @@ -5,12 +16,14 @@ Copy nvim directory to ~/.nvim Open Vim and run `:PlugInstall` -For getting the Yubikey working, - +For getting the Yubikey working +=============================== Copy the .gnupg directory from a computere where its working. Then install these relevant debian things: https://github.com/drduh/YubiKey-Guide#debian-and-ubuntu + + Other tools to install ====================== A big list of options: https://github.com/ibraheemdev/modern-Unix diff --git a/gitconfig.core b/gitconfig.core new file mode 100644 index 0000000..08478ea --- /dev/null +++ b/gitconfig.core @@ -0,0 +1,40 @@ +[includeIf "gitdir:~/work/"] + path = src/dotfiles/gitconfig.work + +[includeIf "gitdir:~/src/"] + path = src/dotfiles/gitconfig.personal + +[color] + ui = true + +[branch] + autosetuprebase = always + autosetupmerge = true + +[push] + default = current +[pager] + branch = false + +[core] + pager = delta + +[interactive] + diffFilter = delta --color-only + +[diff] + colorMoved = default + +[delta] + navigate = true + line-numbers = true + features = decorations + +[delta "decorations"] + commit-decoration-style = blue ol + commit-style = raw + file-style = omit + hunk-header-decoration-style = blue box + hunk-header-file-style = red + hunk-header-line-number-style = "#067a00" + hunk-header-style = file line-number syntax diff --git a/gitconfig.edx b/gitconfig.edx deleted file mode 100644 index 31fb60a..0000000 --- a/gitconfig.edx +++ /dev/null @@ -1,18 +0,0 @@ -[user] - email = feanil@edx.org - name = Feanil Patel - -[color] - ui = true - -[branch] - autosetuprebase = always - autosetupmerge = true - -[push] - default = current - - -[commit] -# gpgsign = true -# diff --git a/gitconfig.personal b/gitconfig.personal index 942bf82..0b71b10 100644 --- a/gitconfig.personal +++ b/gitconfig.personal @@ -1,38 +1,3 @@ [user] email = feanil@gmail.com name = Feanil Patel - -[color] - ui = true - -[branch] - autosetuprebase = always - autosetupmerge = true - -[push] - default = current -[pager] - branch = false - -[core] - pager = delta - -[interactive] - diffFilter = delta --color-only - -[diff] - colorMoved = default - -[delta] - navigate = true - line-numbers = true - features = decorations - -[delta "decorations"] - commit-decoration-style = blue ol - commit-style = raw - file-style = omit - hunk-header-decoration-style = blue box - hunk-header-file-style = red - hunk-header-line-number-style = "#067a00" - hunk-header-style = file line-number syntax diff --git a/gitconfig.work b/gitconfig.work new file mode 100644 index 0000000..d5a5f2a --- /dev/null +++ b/gitconfig.work @@ -0,0 +1,3 @@ +[user] + email = feanil@tcril.org + name = Feanil Patel From 1604149e3a5cd6f0dd81c828378dc25396e16559 Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Fri, 25 Feb 2022 09:40:47 -0500 Subject: [PATCH 03/67] Turn on some more plugins. --- config/nvim/init.vim | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/config/nvim/init.vim b/config/nvim/init.vim index 6c1a40a..b6e18d2 100644 --- a/config/nvim/init.vim +++ b/config/nvim/init.vim @@ -9,7 +9,7 @@ let g:python3_host_prog = '/home/feanil/.virtualenvs/neovim/bin/python' let g:vim_isort_python_version = 'python3' let g:black_virtualenv = '/home/feanil/.virtualenvs/neovim' -let g:black_linelength = 99 +"let g:black_linelength = 99 call plug#begin('~/.local/share/nvim/plugged') @@ -22,7 +22,8 @@ Plug 'psf/black' Plug 'fisadev/vim-isort' " Plug 'ycm-core/YouCompleteMe' Plug 'hashivim/vim-terraform' - +Plug 'scrooloose/nerdtree', { 'on': 'NERDTreeFind' } +Plug 'junegunn/fzf' " Make sure you use single quotes " Shorthand notation; fetches https://github.com/junegunn/vim-easy-alignPlug 'junegunn/vim-easy-align' @@ -63,6 +64,10 @@ autocmd BufWritePre ~/src/personal/*.py execute ':Isort' autocmd BufWritePre ~/work/src/hacking/*.py execute ':Black' autocmd BufWritePre ~/work/src/hacking/*.py execute ':Isort' +" open edX things are using these +autocmd BufWritePre ~/work/src/openedx/terraform-github/*.py execute ':Black' +autocmd BufWritePre ~/work/src/openedx/terraform-github/*.py execute ':Isort' + set number set ruler From 0a65230d6fb8a29586958e0ad1ebf6e0473fd75e Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Fri, 25 Feb 2022 09:41:23 -0500 Subject: [PATCH 04/67] Add some more places where there are git repos. --- gitconfig.core | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/gitconfig.core b/gitconfig.core index 08478ea..f9acd78 100644 --- a/gitconfig.core +++ b/gitconfig.core @@ -4,6 +4,12 @@ [includeIf "gitdir:~/src/"] path = src/dotfiles/gitconfig.personal +[includeIf "gitdir:~/.password-store/"] + path = src/dotfiles/gitconfig.personal + +[includeIf "gitdir:~/notes/org-notes/"] + path = src/dotfiles/gitconfig.personal + [color] ui = true From 690cb62cd284cf1f7b5cafebdc37c53bc35a6753 Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Fri, 25 Feb 2022 09:46:10 -0500 Subject: [PATCH 05/67] Update xmonad config. * Turn the screensaver back on and update the lock command. * Add a new shortcut for swapping capslock with ctrl. * Fix some inconsistent tab and space usage. --- xmonad/xmonad.hs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/xmonad/xmonad.hs b/xmonad/xmonad.hs index bd5b6d4..f4283d2 100644 --- a/xmonad/xmonad.hs +++ b/xmonad/xmonad.hs @@ -7,12 +7,13 @@ import System.IO main = do spawnPipe "sleep 2; xfce4-panel -r;" spawnPipe "synclient MaxTapTime=0" --- spawnPipe "killall xautolock; xautolock -time 5 -locker 'gnome-screensaver-command -l';" + spawnPipe "killall xautolock; xautolock -time 5 -locker 'xdg-screensaver lock';" spawnPipe "setxkbmap -option 'ctrl:nocaps'" xmonad $ xfceConfig { modMask = mod4Mask -- Rebind Mod to the Windows key } `additionalKeys` - [ ((mod4Mask .|. shiftMask, xK_Return), spawn "xfce4-terminal") - , ((mod4Mask , xK_p), spawn "rofi -show run") - , ((mod4Mask .|. shiftMask, xK_l), spawn "xdg-screensaver lock") - ] + [ ((mod4Mask .|. shiftMask, xK_Return), spawn "xfce4-terminal") + , ((mod4Mask , xK_p), spawn "rofi -show run") + , ((mod4Mask .|. shiftMask, xK_l), spawn "xdg-screensaver lock") + , ((mod4Mask, xK_c), spawn "setxkbmap -option 'ctrl:nocaps'") + ] From f12948144e9bf31016b538fc0da087f27229d6a3 Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Wed, 8 Jun 2022 23:27:20 -0400 Subject: [PATCH 06/67] feat: Add NVM Config. These lines were automatically added when I installed NVM using the instructions on their Readme. https://github.com/nvm-sh/nvm#installing-and-updating --- .zshrc | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.zshrc b/.zshrc index 45606d2..e5beaf6 100644 --- a/.zshrc +++ b/.zshrc @@ -137,3 +137,8 @@ alias cat=bat export MCFLY_INTERFACE_VIEW=BOTTOM export MCFLY_FUZZY=2 eval "$(mcfly init zsh)" + +# https://github.com/nvm-sh/nvm#installing-and-updating +export NVM_DIR="$HOME/.nvm" +[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" # This loads nvm +[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion" # This loads nvm bash_completion From 011747eeccb6c7dcce784784b6fc9dc0619604bd Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Wed, 8 Jun 2022 23:29:18 -0400 Subject: [PATCH 07/67] feat: Default inital branch name to `main` on new repos. When we create git repos locally, have the default branch name be `main` instead of master. --- gitconfig.core | 3 +++ 1 file changed, 3 insertions(+) diff --git a/gitconfig.core b/gitconfig.core index f9acd78..2ac3998 100644 --- a/gitconfig.core +++ b/gitconfig.core @@ -10,6 +10,9 @@ [includeIf "gitdir:~/notes/org-notes/"] path = src/dotfiles/gitconfig.personal +[init] + defaultBranch = main + [color] ui = true From 3610de094f4cfc4c7121db237f4d3b5876ef2543 Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Wed, 8 Jun 2022 23:30:16 -0400 Subject: [PATCH 08/67] feat: Customize layouts to add reflection ability. Added 2 new layouts that put the main pane on the right/bottom of the screen and added some keyboard shortcuts to easily toggle reflection. I could have just added the keyboard shortcuts and not add the now reflected layouts but deciced to do both since I don't know which way I'll prefer to work with them yet. --- xmonad/xmonad.hs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/xmonad/xmonad.hs b/xmonad/xmonad.hs index f4283d2..d082704 100644 --- a/xmonad/xmonad.hs +++ b/xmonad/xmonad.hs @@ -4,6 +4,23 @@ import XMonad.Util.EZConfig(additionalKeys) import XMonad.Config.Xfce import System.IO +-- Use these two to put the master pane on the right +-- https://hackage.haskell.org/package/xmonad-contrib-0.17.0/docs/XMonad-Layout-Reflect.html +import XMonad.Layout.Reflect +import XMonad.Layout.MultiToggle + + +myLayouts = desktopLayoutModifiers + $ mkToggle (single REFLECTX) + $ mkToggle (single REFLECTY) + $ + (reflectHoriz $ Tall 1 0.03 0.5) -- Put the master pane on the right + ||| Tall 1 0.03 0.5 + ||| (reflectVert $ Mirror (Tall 1 0.03 0.5)) -- Put the master pane on the bottom + ||| Mirror (Tall 1 0.03 0.5) + ||| Full + + main = do spawnPipe "sleep 2; xfce4-panel -r;" spawnPipe "synclient MaxTapTime=0" @@ -11,9 +28,15 @@ main = do spawnPipe "setxkbmap -option 'ctrl:nocaps'" xmonad $ xfceConfig { modMask = mod4Mask -- Rebind Mod to the Windows key + -- , layoutHook = myLayouts -- Change the possible layouts + -- , layoutHook = desktopLayoutModifiers $ Mirror (Mirror (Tall 1 0.03 0.5)) -- Change the possible layouts + , layoutHook = myLayouts } `additionalKeys` [ ((mod4Mask .|. shiftMask, xK_Return), spawn "xfce4-terminal") , ((mod4Mask , xK_p), spawn "rofi -show run") , ((mod4Mask .|. shiftMask, xK_l), spawn "xdg-screensaver lock") , ((mod4Mask, xK_c), spawn "setxkbmap -option 'ctrl:nocaps'") + -- Be able to toggle master pane location + , ((mod4Mask, xK_r), sendMessage $ Toggle REFLECTX) + , ((mod4Mask .|. shiftMask, xK_r), sendMessage $ Toggle REFLECTY) ] From 196b186f05be16831143870a4339365e78e3dd5a Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Tue, 19 Jul 2022 14:58:38 -0400 Subject: [PATCH 09/67] feat: Update gitconfig. Rebase on git pull. --- gitconfig.core | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/gitconfig.core b/gitconfig.core index 2ac3998..6dacd58 100644 --- a/gitconfig.core +++ b/gitconfig.core @@ -22,6 +22,7 @@ [push] default = current + [pager] branch = false @@ -47,3 +48,6 @@ hunk-header-file-style = red hunk-header-line-number-style = "#067a00" hunk-header-style = file line-number syntax + +[pull] + rebase = true From 143fd478683ef0e1ec1d322103f4f84334b9c191 Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Tue, 19 Jul 2022 14:59:49 -0400 Subject: [PATCH 10/67] Update zshrc to add a copy alias and update the path. --- .zshrc | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.zshrc b/.zshrc index e5beaf6..a19f739 100644 --- a/.zshrc +++ b/.zshrc @@ -15,7 +15,7 @@ bindkey -e # export PATH=$HOME/bin:/usr/local/bin:$PATH export PATH=$PATH:$HOME/.local/bin -export PATH=$PATH:$HOME/.local/opt/node/bin +export PATH=$PATH:$HOME/.yarn/bin export PATH="$HOME/.poetry/bin:$PATH" # Path to your oh-my-zsh installation. @@ -118,6 +118,7 @@ export LESS=-FRX alias vim=nvim alias open=xdg-open +alias copy=xclip -selection clipboard # Compilation flags # export ARCHFLAGS="-arch x86_64" From 6e31d751f01584a0430df494b0d92b163c8d8c81 Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Mon, 1 Aug 2022 13:19:44 -0400 Subject: [PATCH 11/67] feat: Update which screensaver to use. Use the xfce4 screensaver command for locking the screen. --- xmonad/xmonad.hs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/xmonad/xmonad.hs b/xmonad/xmonad.hs index d082704..ff5bdd8 100644 --- a/xmonad/xmonad.hs +++ b/xmonad/xmonad.hs @@ -24,7 +24,7 @@ myLayouts = desktopLayoutModifiers main = do spawnPipe "sleep 2; xfce4-panel -r;" spawnPipe "synclient MaxTapTime=0" - spawnPipe "killall xautolock; xautolock -time 5 -locker 'xdg-screensaver lock';" + spawnPipe "killall xautolock; xautolock -time 5 -locker 'xfce4-screensaver-command -l';" spawnPipe "setxkbmap -option 'ctrl:nocaps'" xmonad $ xfceConfig { modMask = mod4Mask -- Rebind Mod to the Windows key @@ -34,7 +34,7 @@ main = do } `additionalKeys` [ ((mod4Mask .|. shiftMask, xK_Return), spawn "xfce4-terminal") , ((mod4Mask , xK_p), spawn "rofi -show run") - , ((mod4Mask .|. shiftMask, xK_l), spawn "xdg-screensaver lock") + , ((mod4Mask .|. shiftMask, xK_l), spawn "xfce4-screensaver-command -l") , ((mod4Mask, xK_c), spawn "setxkbmap -option 'ctrl:nocaps'") -- Be able to toggle master pane location , ((mod4Mask, xK_r), sendMessage $ Toggle REFLECTX) From ff60575a99420bfe2773c284adc48b49b80b0271 Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Mon, 1 Aug 2022 13:20:18 -0400 Subject: [PATCH 12/67] feat: Link the neovim clipboard to the system clipboards. Thanks to Kyle McCormick for the initial tip and the below link for some details on how to pull in both clipboards. https://stackoverflow.com/questions/30691466/what-is-difference-between-vims-clipboard-unnamed-and-unnamedplus-settings --- config/nvim/init.vim | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/config/nvim/init.vim b/config/nvim/init.vim index b6e18d2..91f1713 100644 --- a/config/nvim/init.vim +++ b/config/nvim/init.vim @@ -60,16 +60,19 @@ call plug#end() autocmd BufWritePre ~/src/personal/*.py execute ':Black' autocmd BufWritePre ~/src/personal/*.py execute ':Isort' -" edX things are not using these yet but we should for hacking. +" Open edX things are not using these yet but we should for hacking. autocmd BufWritePre ~/work/src/hacking/*.py execute ':Black' autocmd BufWritePre ~/work/src/hacking/*.py execute ':Isort' -" open edX things are using these +" Open edX things that have black and isort enabled autocmd BufWritePre ~/work/src/openedx/terraform-github/*.py execute ':Black' autocmd BufWritePre ~/work/src/openedx/terraform-github/*.py execute ':Isort' set number set ruler +" Reference https://stackoverflow.com/questions/30691466/what-is-difference-between-vims-clipboard-unnamed-and-unnamedplus-settings +set clipboard^=unnamed,unnamedplus + nmap :NERDTreeFind nmap :FZF From a102d038c38103df2c8fb109e8e8d9c84ce04d7a Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Tue, 6 Sep 2022 13:33:28 -0400 Subject: [PATCH 13/67] poetry now installs to .local/bin so no need for a specific override. --- .zshrc | 1 - 1 file changed, 1 deletion(-) diff --git a/.zshrc b/.zshrc index a19f739..e9558a4 100644 --- a/.zshrc +++ b/.zshrc @@ -16,7 +16,6 @@ bindkey -e export PATH=$PATH:$HOME/.local/bin export PATH=$PATH:$HOME/.yarn/bin -export PATH="$HOME/.poetry/bin:$PATH" # Path to your oh-my-zsh installation. export ZSH="/home/feanil/.oh-my-zsh" From 981b77364a12efc01968a9cb82008219251a2d9e Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Tue, 6 Sep 2022 13:36:09 -0400 Subject: [PATCH 14/67] Add more overrides for black and isort in nvim. --- config/nvim/init.vim | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/config/nvim/init.vim b/config/nvim/init.vim index 91f1713..55c8ec2 100644 --- a/config/nvim/init.vim +++ b/config/nvim/init.vim @@ -10,6 +10,7 @@ let g:vim_isort_python_version = 'python3' let g:black_virtualenv = '/home/feanil/.virtualenvs/neovim' "let g:black_linelength = 99 +let g:black_quiet = 1 call plug#begin('~/.local/share/nvim/plugged') @@ -65,9 +66,18 @@ autocmd BufWritePre ~/work/src/hacking/*.py execute ':Black' autocmd BufWritePre ~/work/src/hacking/*.py execute ':Isort' " Open edX things that have black and isort enabled +" terraform-github autocmd BufWritePre ~/work/src/openedx/terraform-github/*.py execute ':Black' autocmd BufWritePre ~/work/src/openedx/terraform-github/*.py execute ':Isort' +" docs.openedx.org +autocmd BufWritePre ~/work/src/openedx/docs.openedx.org/*.py execute ':Black' +autocmd BufWritePre ~/work/src/openedx/docs.openedx.org/*.py execute ':Isort' + +" my work related repos +autocmd BufWritePre ~/work/src/feanil/*.py execute ':Black' +autocmd BufWritePre ~/work/src/feanil/*.py execute ':Isort' + set number set ruler From 4bdf990649c2bd90c8881ac2518e69a3324eeb0f Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Tue, 6 Sep 2022 13:39:43 -0400 Subject: [PATCH 15/67] Wait longer to restart xfce4-panel. --- xmonad/xmonad.hs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/xmonad/xmonad.hs b/xmonad/xmonad.hs index ff5bdd8..4bce335 100644 --- a/xmonad/xmonad.hs +++ b/xmonad/xmonad.hs @@ -22,7 +22,7 @@ myLayouts = desktopLayoutModifiers main = do - spawnPipe "sleep 2; xfce4-panel -r;" + spawnPipe "sleep 4; xfce4-panel -r;" spawnPipe "synclient MaxTapTime=0" spawnPipe "killall xautolock; xautolock -time 5 -locker 'xfce4-screensaver-command -l';" spawnPipe "setxkbmap -option 'ctrl:nocaps'" From 20c23ca876bb5680f6b0179cb1d259087dfd8cbb Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Tue, 6 Sep 2022 13:46:31 -0400 Subject: [PATCH 16/67] Sign git commits with my ssh key. --- git_allowed_signers | 2 ++ gitconfig.core | 12 +++++++++++- gitconfig.personal | 1 + gitconfig.work | 5 +++-- 4 files changed, 17 insertions(+), 3 deletions(-) create mode 100644 git_allowed_signers diff --git a/git_allowed_signers b/git_allowed_signers new file mode 100644 index 0000000..1224e3d --- /dev/null +++ b/git_allowed_signers @@ -0,0 +1,2 @@ +feanil@tcril.org ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDH1ZvaTRhSE6eS7RNwUwFcTUbkK4/WFgO3TaW1S4Gr+9FKkJ8rzBusSCYPbwS6XgEJGnLB2qBQlnK3EvSmljKgVLBeruxiJInz1Gx7184NOvKDmE4+gFe46MThNl7FwA02NwYk0tnLHu9zYL3DjS1um6Txy/K0GEyqliGGq8tcuBwWb8xKlE7/aTEu1zaqH592q4JB7zDUV/iNuxUKU70EAu2gzHmmC7AhewYnVoVKou6YBvlvB5pfVgHgdcHF7EBV2mIidIwQzDDzDyLRg5zxTZ/ABfjhd/2En6o2NYEftOWqSbzfmEInBavvIeqyJExPvUVh7EWs635dZ6JB9NVhNJY0u58xTAItWL3ZNFlmv1ENvY2UKsU5b2NnLFopezuNreouIb6jE6zHqrT1z2jYfMYO3/hAMY3/NyCyvPrJiQd/obP7yDswq1fgXEKt3VJoMgMcEIBt/Yl7RhGwnawAO3HujFk+cAOmFA10zd4j3svRZ9ZMO8tjD5BSkonxE89YvLb5IPCLR4V1l85g3DVF8tHxp+SJWTX2ma8LGGg1/bjvkgAZ2P8w/qEF8Iw0ONgYUuaJvfDsHv9KjoBWXzbhjcnlfA2UFRTuNuGFRKkip6+BKhGcFBE1U5z1H6NufOc2f6rNwGoN/+0+kCcf/YnvCBx4j4c13k3DHelaH6FG2Q== feanil@gmail.com +feanil@gmail.com ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDH1ZvaTRhSE6eS7RNwUwFcTUbkK4/WFgO3TaW1S4Gr+9FKkJ8rzBusSCYPbwS6XgEJGnLB2qBQlnK3EvSmljKgVLBeruxiJInz1Gx7184NOvKDmE4+gFe46MThNl7FwA02NwYk0tnLHu9zYL3DjS1um6Txy/K0GEyqliGGq8tcuBwWb8xKlE7/aTEu1zaqH592q4JB7zDUV/iNuxUKU70EAu2gzHmmC7AhewYnVoVKou6YBvlvB5pfVgHgdcHF7EBV2mIidIwQzDDzDyLRg5zxTZ/ABfjhd/2En6o2NYEftOWqSbzfmEInBavvIeqyJExPvUVh7EWs635dZ6JB9NVhNJY0u58xTAItWL3ZNFlmv1ENvY2UKsU5b2NnLFopezuNreouIb6jE6zHqrT1z2jYfMYO3/hAMY3/NyCyvPrJiQd/obP7yDswq1fgXEKt3VJoMgMcEIBt/Yl7RhGwnawAO3HujFk+cAOmFA10zd4j3svRZ9ZMO8tjD5BSkonxE89YvLb5IPCLR4V1l85g3DVF8tHxp+SJWTX2ma8LGGg1/bjvkgAZ2P8w/qEF8Iw0ONgYUuaJvfDsHv9KjoBWXzbhjcnlfA2UFRTuNuGFRKkip6+BKhGcFBE1U5z1H6NufOc2f6rNwGoN/+0+kCcf/YnvCBx4j4c13k3DHelaH6FG2Q== feanil@gmail.com diff --git a/gitconfig.core b/gitconfig.core index 6dacd58..ca80930 100644 --- a/gitconfig.core +++ b/gitconfig.core @@ -22,6 +22,7 @@ [push] default = current + pushOption = u [pager] branch = false @@ -50,4 +51,13 @@ hunk-header-style = file line-number syntax [pull] - rebase = true + rebase = true + +[gpg] + format = ssh + +[gpg "ssh"] + allowedSignersFile = /home/feanil/src/dotfiles/git_allowed_signers + +[commit] + gpgsign = true diff --git a/gitconfig.personal b/gitconfig.personal index 0b71b10..63c4b66 100644 --- a/gitconfig.personal +++ b/gitconfig.personal @@ -1,3 +1,4 @@ [user] email = feanil@gmail.com name = Feanil Patel + signingkey = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDH1ZvaTRhSE6eS7RNwUwFcTUbkK4/WFgO3TaW1S4Gr+9FKkJ8rzBusSCYPbwS6XgEJGnLB2qBQlnK3EvSmljKgVLBeruxiJInz1Gx7184NOvKDmE4+gFe46MThNl7FwA02NwYk0tnLHu9zYL3DjS1um6Txy/K0GEyqliGGq8tcuBwWb8xKlE7/aTEu1zaqH592q4JB7zDUV/iNuxUKU70EAu2gzHmmC7AhewYnVoVKou6YBvlvB5pfVgHgdcHF7EBV2mIidIwQzDDzDyLRg5zxTZ/ABfjhd/2En6o2NYEftOWqSbzfmEInBavvIeqyJExPvUVh7EWs635dZ6JB9NVhNJY0u58xTAItWL3ZNFlmv1ENvY2UKsU5b2NnLFopezuNreouIb6jE6zHqrT1z2jYfMYO3/hAMY3/NyCyvPrJiQd/obP7yDswq1fgXEKt3VJoMgMcEIBt/Yl7RhGwnawAO3HujFk+cAOmFA10zd4j3svRZ9ZMO8tjD5BSkonxE89YvLb5IPCLR4V1l85g3DVF8tHxp+SJWTX2ma8LGGg1/bjvkgAZ2P8w/qEF8Iw0ONgYUuaJvfDsHv9KjoBWXzbhjcnlfA2UFRTuNuGFRKkip6+BKhGcFBE1U5z1H6NufOc2f6rNwGoN/+0+kCcf/YnvCBx4j4c13k3DHelaH6FG2Q== feanil@gmail.com" diff --git a/gitconfig.work b/gitconfig.work index d5a5f2a..474f4fc 100644 --- a/gitconfig.work +++ b/gitconfig.work @@ -1,3 +1,4 @@ [user] - email = feanil@tcril.org - name = Feanil Patel + email = feanil@tcril.org + name = Feanil Patel + signingkey = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDH1ZvaTRhSE6eS7RNwUwFcTUbkK4/WFgO3TaW1S4Gr+9FKkJ8rzBusSCYPbwS6XgEJGnLB2qBQlnK3EvSmljKgVLBeruxiJInz1Gx7184NOvKDmE4+gFe46MThNl7FwA02NwYk0tnLHu9zYL3DjS1um6Txy/K0GEyqliGGq8tcuBwWb8xKlE7/aTEu1zaqH592q4JB7zDUV/iNuxUKU70EAu2gzHmmC7AhewYnVoVKou6YBvlvB5pfVgHgdcHF7EBV2mIidIwQzDDzDyLRg5zxTZ/ABfjhd/2En6o2NYEftOWqSbzfmEInBavvIeqyJExPvUVh7EWs635dZ6JB9NVhNJY0u58xTAItWL3ZNFlmv1ENvY2UKsU5b2NnLFopezuNreouIb6jE6zHqrT1z2jYfMYO3/hAMY3/NyCyvPrJiQd/obP7yDswq1fgXEKt3VJoMgMcEIBt/Yl7RhGwnawAO3HujFk+cAOmFA10zd4j3svRZ9ZMO8tjD5BSkonxE89YvLb5IPCLR4V1l85g3DVF8tHxp+SJWTX2ma8LGGg1/bjvkgAZ2P8w/qEF8Iw0ONgYUuaJvfDsHv9KjoBWXzbhjcnlfA2UFRTuNuGFRKkip6+BKhGcFBE1U5z1H6NufOc2f6rNwGoN/+0+kCcf/YnvCBx4j4c13k3DHelaH6FG2Q== feanil@gmail.com" From 114ad5bc9a316ba1779508568a287115c37d99ec Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Tue, 6 Sep 2022 13:48:13 -0400 Subject: [PATCH 17/67] Also sign tags. --- gitconfig.core | 3 +++ 1 file changed, 3 insertions(+) diff --git a/gitconfig.core b/gitconfig.core index ca80930..14045d1 100644 --- a/gitconfig.core +++ b/gitconfig.core @@ -61,3 +61,6 @@ [commit] gpgsign = true + +[tag] + gpgsign = true From bb90b88ebc48a75188403824c0400d8400af4289 Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Tue, 7 Mar 2023 12:45:45 -0500 Subject: [PATCH 18/67] feat: Add gittreeif to my zsh profile. --- .zshrc | 4 +++ config/shellrc/gittree.sh | 55 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 config/shellrc/gittree.sh diff --git a/.zshrc b/.zshrc index e9558a4..d290216 100644 --- a/.zshrc +++ b/.zshrc @@ -91,6 +91,7 @@ plugins=( virtualenvwrapper fzf pass + poetry ) ZSH_TMUX_AUTOSTART=true @@ -142,3 +143,6 @@ eval "$(mcfly init zsh)" export NVM_DIR="$HOME/.nvm" [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" # This loads nvm [ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion" # This loads nvm bash_completion +export PATH="/home/feanil/work/src/openedx/edx-platform:$PATH" + +[[ -f ~/src/dotfiles/config/shellrc/gittree.sh ]] && source ~/src/dotfiles/config/shellrc/gittree.sh diff --git a/config/shellrc/gittree.sh b/config/shellrc/gittree.sh new file mode 100644 index 0000000..d980850 --- /dev/null +++ b/config/shellrc/gittree.sh @@ -0,0 +1,55 @@ +# Copied from https://github.com/nedbat/dot/blob/master/.config/shellrc/gittree.sh +# +# Run a command for every repo found somewhere beneath the current directory. +# +# $ gittree git fetch --all --prune +# +# To only run commands in repos with a particular branch, use gittreeif: +# +# $ gittreeif branch_name git fetch --all --prune +# +# If the command has subcommands that need to run in each directory, quote the +# entire command: +# +# $ gittreeif origin/foo 'git log --format="%s" origin/foo ^$(git merge-base origin/master origin/foo)' +# +# The directory name is printed before each command. Use -q to suppress this, +# or -r to show the origin remote url instead of the directory name. +# +# $ gittreeif origin/foo -q git status +# +gittreeif() { + local test_branch="$1" + shift + local show_dir=true show_repo=false + if [[ $1 == -r ]]; then + # -r means, show the remote url instead of the directory. + shift + local show_dir=false show_repo=true + fi + if [[ $1 == -q ]]; then + # -q means, don't echo the separator line with the directory. + shift + local show_dir=false show_repo=false + fi + find . -name .git -type d -prune | while read d; do + local d=$(dirname "$d") + git -C "$d" rev-parse --verify -q "$test_branch" >& /dev/null || continue + if [[ $show_dir == true ]]; then + echo "---- $d ----" + fi + if [[ $show_repo == true ]]; then + echo "----" $(git -C "$d" config --get remote.origin.url) "----" + fi + if [[ $# == 1 && $1 == *' '* ]]; then + (cd "$d" && eval "$1") + else + (cd "$d" && "$@") + fi + done +} + +gittree() { + # @ is in every repo, so this runs on all repos + gittreeif @ "$@" +} From 657814be415cce78cc86a857e4cae62c730cb4c4 Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Tue, 18 Apr 2023 13:35:45 -0400 Subject: [PATCH 19/67] Updates based on a new machine. --- README.md | 51 ++++++++++++++++++++++++++++++++++----------- git_allowed_signers | 3 ++- gitconfig.core | 12 ++++++----- gitconfig.work | 4 ++-- 4 files changed, 50 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 0b3c755..b7924ba 100644 --- a/README.md +++ b/README.md @@ -6,12 +6,49 @@ ln -sf src/dotfiles/gitconfig.core ~/.gitconfig Update as necessary for work and personal paths. +Setup Python and Virtualenvwrapper +================================== + +Install python3 python3-dev +Install virtualenvwrapper + + +tmux +==== + +Install tmux +ln -sf ~/src/feanil/dotfiles/tmux.conf .tmux.conf + + +mcfly +===== + +- `ctrl-r` replacement - https://github.com/cantino/mcfly + +Instructions - https://github.com/cantino/mcfly#installing-using-our-install-script + +Release Page - https://github.com/cantino/mcfly/releases +- Get the musl version which will be fully statically linked. + +Setup ZSH and oh-my-zsh +======================= + +Install zsh fzf +Follow the instructions to install oh-my-zsh: https://ohmyz.sh/#install +Install powerlevel10k theme https://github.com/romkatv/powerlevel10k#oh-my-zsh + +ln -sf ~/src/feanil/dotfiles/.p10k.zsh .p10k.zsh +ln -sf ~/src/feanil/dotfiles/.zshrc .zshrc + + VIM === +sudo add-apt-repository ppa:neovim-ppa/stable Install NeoVim -Copy nvimrc to ~/.nvimrc -Copy nvim directory to ~/.nvim +mkvirtualenv neovim +pip install black isort neovim +ln -sf ~/src/feanil/dotfiles/config/nvim .config/nvim Open Vim and run `:PlugInstall` @@ -37,16 +74,6 @@ bat Releases Page - https://github.com/sharkdp/bat/releases - Get the musl version which will be fully statically linked. -mcfly ------ - -- `ctrl-r` replacement - https://github.com/cantino/mcfly - -Instructions - https://github.com/cantino/mcfly#installing-using-our-install-script - -Release Page - https://github.com/cantino/mcfly/releases -- Get the musl version which will be fully statically linked. - exa --- diff --git a/git_allowed_signers b/git_allowed_signers index 1224e3d..f4dd731 100644 --- a/git_allowed_signers +++ b/git_allowed_signers @@ -1,2 +1,3 @@ -feanil@tcril.org ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDH1ZvaTRhSE6eS7RNwUwFcTUbkK4/WFgO3TaW1S4Gr+9FKkJ8rzBusSCYPbwS6XgEJGnLB2qBQlnK3EvSmljKgVLBeruxiJInz1Gx7184NOvKDmE4+gFe46MThNl7FwA02NwYk0tnLHu9zYL3DjS1um6Txy/K0GEyqliGGq8tcuBwWb8xKlE7/aTEu1zaqH592q4JB7zDUV/iNuxUKU70EAu2gzHmmC7AhewYnVoVKou6YBvlvB5pfVgHgdcHF7EBV2mIidIwQzDDzDyLRg5zxTZ/ABfjhd/2En6o2NYEftOWqSbzfmEInBavvIeqyJExPvUVh7EWs635dZ6JB9NVhNJY0u58xTAItWL3ZNFlmv1ENvY2UKsU5b2NnLFopezuNreouIb6jE6zHqrT1z2jYfMYO3/hAMY3/NyCyvPrJiQd/obP7yDswq1fgXEKt3VJoMgMcEIBt/Yl7RhGwnawAO3HujFk+cAOmFA10zd4j3svRZ9ZMO8tjD5BSkonxE89YvLb5IPCLR4V1l85g3DVF8tHxp+SJWTX2ma8LGGg1/bjvkgAZ2P8w/qEF8Iw0ONgYUuaJvfDsHv9KjoBWXzbhjcnlfA2UFRTuNuGFRKkip6+BKhGcFBE1U5z1H6NufOc2f6rNwGoN/+0+kCcf/YnvCBx4j4c13k3DHelaH6FG2Q== feanil@gmail.com +feanil@axim.org ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDH1ZvaTRhSE6eS7RNwUwFcTUbkK4/WFgO3TaW1S4Gr+9FKkJ8rzBusSCYPbwS6XgEJGnLB2qBQlnK3EvSmljKgVLBeruxiJInz1Gx7184NOvKDmE4+gFe46MThNl7FwA02NwYk0tnLHu9zYL3DjS1um6Txy/K0GEyqliGGq8tcuBwWb8xKlE7/aTEu1zaqH592q4JB7zDUV/iNuxUKU70EAu2gzHmmC7AhewYnVoVKou6YBvlvB5pfVgHgdcHF7EBV2mIidIwQzDDzDyLRg5zxTZ/ABfjhd/2En6o2NYEftOWqSbzfmEInBavvIeqyJExPvUVh7EWs635dZ6JB9NVhNJY0u58xTAItWL3ZNFlmv1ENvY2UKsU5b2NnLFopezuNreouIb6jE6zHqrT1z2jYfMYO3/hAMY3/NyCyvPrJiQd/obP7yDswq1fgXEKt3VJoMgMcEIBt/Yl7RhGwnawAO3HujFk+cAOmFA10zd4j3svRZ9ZMO8tjD5BSkonxE89YvLb5IPCLR4V1l85g3DVF8tHxp+SJWTX2ma8LGGg1/bjvkgAZ2P8w/qEF8Iw0ONgYUuaJvfDsHv9KjoBWXzbhjcnlfA2UFRTuNuGFRKkip6+BKhGcFBE1U5z1H6NufOc2f6rNwGoN/+0+kCcf/YnvCBx4j4c13k3DHelaH6FG2Q== feanil@gmail.com feanil@gmail.com ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDH1ZvaTRhSE6eS7RNwUwFcTUbkK4/WFgO3TaW1S4Gr+9FKkJ8rzBusSCYPbwS6XgEJGnLB2qBQlnK3EvSmljKgVLBeruxiJInz1Gx7184NOvKDmE4+gFe46MThNl7FwA02NwYk0tnLHu9zYL3DjS1um6Txy/K0GEyqliGGq8tcuBwWb8xKlE7/aTEu1zaqH592q4JB7zDUV/iNuxUKU70EAu2gzHmmC7AhewYnVoVKou6YBvlvB5pfVgHgdcHF7EBV2mIidIwQzDDzDyLRg5zxTZ/ABfjhd/2En6o2NYEftOWqSbzfmEInBavvIeqyJExPvUVh7EWs635dZ6JB9NVhNJY0u58xTAItWL3ZNFlmv1ENvY2UKsU5b2NnLFopezuNreouIb6jE6zHqrT1z2jYfMYO3/hAMY3/NyCyvPrJiQd/obP7yDswq1fgXEKt3VJoMgMcEIBt/Yl7RhGwnawAO3HujFk+cAOmFA10zd4j3svRZ9ZMO8tjD5BSkonxE89YvLb5IPCLR4V1l85g3DVF8tHxp+SJWTX2ma8LGGg1/bjvkgAZ2P8w/qEF8Iw0ONgYUuaJvfDsHv9KjoBWXzbhjcnlfA2UFRTuNuGFRKkip6+BKhGcFBE1U5z1H6NufOc2f6rNwGoN/+0+kCcf/YnvCBx4j4c13k3DHelaH6FG2Q== feanil@gmail.com +feanil@axim.org ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAxWEVmi5CYIs2zrwPPC9VrVigKz9e37p8WJsSkpGFQp feanil@axim.org diff --git a/gitconfig.core b/gitconfig.core index 14045d1..623b5df 100644 --- a/gitconfig.core +++ b/gitconfig.core @@ -1,14 +1,14 @@ [includeIf "gitdir:~/work/"] - path = src/dotfiles/gitconfig.work + path = src/feanil/dotfiles/gitconfig.work [includeIf "gitdir:~/src/"] - path = src/dotfiles/gitconfig.personal + path = src/feanil/dotfiles/gitconfig.personal [includeIf "gitdir:~/.password-store/"] - path = src/dotfiles/gitconfig.personal + path = src/feanil/dotfiles/gitconfig.personal [includeIf "gitdir:~/notes/org-notes/"] - path = src/dotfiles/gitconfig.personal + path = src/feanil/dotfiles/gitconfig.personal [init] defaultBranch = main @@ -57,10 +57,12 @@ format = ssh [gpg "ssh"] - allowedSignersFile = /home/feanil/src/dotfiles/git_allowed_signers + allowedSignersFile = /home/feanil/src/feanil/dotfiles/git_allowed_signers [commit] gpgsign = true [tag] gpgsign = true +[user] + signingkey = /home/feanil/.ssh/id_ed25519.pub diff --git a/gitconfig.work b/gitconfig.work index 474f4fc..39cc3d9 100644 --- a/gitconfig.work +++ b/gitconfig.work @@ -1,4 +1,4 @@ [user] - email = feanil@tcril.org + email = feanil@axim.org name = Feanil Patel - signingkey = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDH1ZvaTRhSE6eS7RNwUwFcTUbkK4/WFgO3TaW1S4Gr+9FKkJ8rzBusSCYPbwS6XgEJGnLB2qBQlnK3EvSmljKgVLBeruxiJInz1Gx7184NOvKDmE4+gFe46MThNl7FwA02NwYk0tnLHu9zYL3DjS1um6Txy/K0GEyqliGGq8tcuBwWb8xKlE7/aTEu1zaqH592q4JB7zDUV/iNuxUKU70EAu2gzHmmC7AhewYnVoVKou6YBvlvB5pfVgHgdcHF7EBV2mIidIwQzDDzDyLRg5zxTZ/ABfjhd/2En6o2NYEftOWqSbzfmEInBavvIeqyJExPvUVh7EWs635dZ6JB9NVhNJY0u58xTAItWL3ZNFlmv1ENvY2UKsU5b2NnLFopezuNreouIb6jE6zHqrT1z2jYfMYO3/hAMY3/NyCyvPrJiQd/obP7yDswq1fgXEKt3VJoMgMcEIBt/Yl7RhGwnawAO3HujFk+cAOmFA10zd4j3svRZ9ZMO8tjD5BSkonxE89YvLb5IPCLR4V1l85g3DVF8tHxp+SJWTX2ma8LGGg1/bjvkgAZ2P8w/qEF8Iw0ONgYUuaJvfDsHv9KjoBWXzbhjcnlfA2UFRTuNuGFRKkip6+BKhGcFBE1U5z1H6NufOc2f6rNwGoN/+0+kCcf/YnvCBx4j4c13k3DHelaH6FG2Q== feanil@gmail.com" + signingkey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAxWEVmi5CYIs2zrwPPC9VrVigKz9e37p8WJsSkpGFQp feanil@axim.org" From 9fc40844aa0e71e14b846a49ebbf121129a82d33 Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Tue, 18 Apr 2023 13:42:41 -0400 Subject: [PATCH 20/67] feat: Update signng key. --- gitconfig.core | 7 +------ gitconfig.work | 2 +- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/gitconfig.core b/gitconfig.core index 623b5df..55c44a4 100644 --- a/gitconfig.core +++ b/gitconfig.core @@ -1,8 +1,5 @@ -[includeIf "gitdir:~/work/"] - path = src/feanil/dotfiles/gitconfig.work - [includeIf "gitdir:~/src/"] - path = src/feanil/dotfiles/gitconfig.personal + path = src/feanil/dotfiles/gitconfig.work [includeIf "gitdir:~/.password-store/"] path = src/feanil/dotfiles/gitconfig.personal @@ -64,5 +61,3 @@ [tag] gpgsign = true -[user] - signingkey = /home/feanil/.ssh/id_ed25519.pub diff --git a/gitconfig.work b/gitconfig.work index 39cc3d9..bb3ba5e 100644 --- a/gitconfig.work +++ b/gitconfig.work @@ -1,4 +1,4 @@ [user] email = feanil@axim.org name = Feanil Patel - signingkey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAxWEVmi5CYIs2zrwPPC9VrVigKz9e37p8WJsSkpGFQp feanil@axim.org" + signingkey = /home/feanil/.ssh/signing_public_key From a60df0b20f805cfba04c52aae93e1b34b5916b7b Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Thu, 12 Dec 2024 21:56:01 -0500 Subject: [PATCH 21/67] Simplify config. --- .zshrc | 2 +- gitconfig.core | 2 -- xmonad/xmonad.hs | 7 +++++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.zshrc b/.zshrc index d290216..8f03550 100644 --- a/.zshrc +++ b/.zshrc @@ -91,7 +91,7 @@ plugins=( virtualenvwrapper fzf pass - poetry + brew ) ZSH_TMUX_AUTOSTART=true diff --git a/gitconfig.core b/gitconfig.core index 55c44a4..81b7232 100644 --- a/gitconfig.core +++ b/gitconfig.core @@ -15,11 +15,9 @@ [branch] autosetuprebase = always - autosetupmerge = true [push] default = current - pushOption = u [pager] branch = false diff --git a/xmonad/xmonad.hs b/xmonad/xmonad.hs index 4bce335..b4f024a 100644 --- a/xmonad/xmonad.hs +++ b/xmonad/xmonad.hs @@ -1,4 +1,5 @@ import XMonad +import XMonad.Actions.WithAll import XMonad.Util.Run import XMonad.Util.EZConfig(additionalKeys) import XMonad.Config.Xfce @@ -22,12 +23,13 @@ myLayouts = desktopLayoutModifiers main = do - spawnPipe "sleep 4; xfce4-panel -r;" + -- spawnPipe "sleep 4; xfce4-panel -r;" spawnPipe "synclient MaxTapTime=0" - spawnPipe "killall xautolock; xautolock -time 5 -locker 'xfce4-screensaver-command -l';" + -- spawnPipe "killall xautolock; xautolock -time 5 -locker 'xfce4-screensaver-command -l';" spawnPipe "setxkbmap -option 'ctrl:nocaps'" xmonad $ xfceConfig { modMask = mod4Mask -- Rebind Mod to the Windows key + -- , focusedBorderColor = "#000000" -- , layoutHook = myLayouts -- Change the possible layouts -- , layoutHook = desktopLayoutModifiers $ Mirror (Mirror (Tall 1 0.03 0.5)) -- Change the possible layouts , layoutHook = myLayouts @@ -39,4 +41,5 @@ main = do -- Be able to toggle master pane location , ((mod4Mask, xK_r), sendMessage $ Toggle REFLECTX) , ((mod4Mask .|. shiftMask, xK_r), sendMessage $ Toggle REFLECTY) + , ((mod4Mask .|. shiftMask, xK_t), sinkAll) ] From 1bc2314d5b0961c2a835966dc13fe1ed058b0bd0 Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Fri, 13 Dec 2024 09:58:17 -0500 Subject: [PATCH 22/67] Fix: Push config from work computer. --- .p10k.zsh | 2 +- .zshrc | 10 ++++++++-- config/nvim/spell/en.utf-8.add | 14 ++++++++++++++ config/nvim/spell/en.utf-8.add.spl | Bin 0 -> 220 bytes 4 files changed, 23 insertions(+), 3 deletions(-) create mode 100644 config/nvim/spell/en.utf-8.add create mode 100644 config/nvim/spell/en.utf-8.add.spl diff --git a/.p10k.zsh b/.p10k.zsh index e0814d5..f269978 100644 --- a/.p10k.zsh +++ b/.p10k.zsh @@ -1598,7 +1598,7 @@ # - verbose: Enable instant prompt and print a warning when detecting console output during # zsh initialization. Choose this if you've never tried instant prompt, haven't # seen the warning, or if you are unsure what this all means. - typeset -g POWERLEVEL9K_INSTANT_PROMPT=verbose + typeset -g POWERLEVEL9K_INSTANT_PROMPT=quiet # Hot reload allows you to change POWERLEVEL9K options after Powerlevel10k has been initialized. # For example, you can type POWERLEVEL9K_BACKGROUND=red and see your prompt turn red. Hot reload diff --git a/.zshrc b/.zshrc index 8f03550..2ef93c8 100644 --- a/.zshrc +++ b/.zshrc @@ -92,13 +92,19 @@ plugins=( fzf pass brew + brew + nvm ) +zstyle ':omz:plugins:nvm' autoload yes + ZSH_TMUX_AUTOSTART=true -export VIRTUALENVWRAPPER_PYTHON=/usr/bin/python3 +export VIRTUALENVWRAPPER_PYTHON=/usr/bin/python3.12 source $ZSH/oh-my-zsh.sh +export FZF_CTRL_T_COMMAND='rg --files --hidden --glob "!.git/*" 2>/dev/null' + # User configuration # export MANPATH="/usr/local/man:$MANPATH" @@ -134,7 +140,7 @@ alias copy=xclip -selection clipboard # To customize prompt, run `p10k configure` or edit ~/.p10k.zsh. [[ -f ~/.p10k.zsh ]] && source ~/.p10k.zsh -alias cat=bat +alias cat=batcat export MCFLY_INTERFACE_VIEW=BOTTOM export MCFLY_FUZZY=2 eval "$(mcfly init zsh)" diff --git a/config/nvim/spell/en.utf-8.add b/config/nvim/spell/en.utf-8.add new file mode 100644 index 0000000..43a65ed --- /dev/null +++ b/config/nvim/spell/en.utf-8.add @@ -0,0 +1,14 @@ +PRs +edX +backend +frontend +MFEs +FrontEnds +edx +LMS +frontends +backends +READMEs +Django +XBlocks +XBlock diff --git a/config/nvim/spell/en.utf-8.add.spl b/config/nvim/spell/en.utf-8.add.spl new file mode 100644 index 0000000000000000000000000000000000000000..f067fb68f3d589aca567e52a54ededd2ecc70333 GIT binary patch literal 220 zcmX|)u?oUK5JYzt5&Qs2;V0Nx*(zQnNI+6Z+QhpEdWj)tkura-6U8*k?Ax80OOl4x zdB1s!h+UM{RlaV!9tEy=z@dRv2@u?g8r<-NDlB>x9nf)Y@cREjWF1f=Kxe2(DF%=x z9hZVO>SK)(o< Date: Sun, 19 Jan 2025 15:11:03 -0500 Subject: [PATCH 23/67] Updates based on OS re-install. --- .zshrc | 4 ---- README.md | 55 ++++++++++++++++++++++++++++++++++---------------- gitconfig.core | 5 ++++- 3 files changed, 42 insertions(+), 22 deletions(-) diff --git a/.zshrc b/.zshrc index 2ef93c8..9eb7208 100644 --- a/.zshrc +++ b/.zshrc @@ -85,13 +85,9 @@ HYPHEN_INSENSITIVE="true" # Example format: plugins=(rails git textmate ruby lighthouse) # Add wisely, as too many plugins slow down shell startup. plugins=( - rust git tmux - virtualenvwrapper fzf - pass - brew brew nvm ) diff --git a/README.md b/README.md index b7924ba..aea0e38 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,55 @@ Git === + Install git -git clone git@github.com/feanil/dotfiles src/dotfiles -ln -sf src/dotfiles/gitconfig.core ~/.gitconfig + +Install github cli - https://github.com/cli/cli/blob/trunk/docs/install_linux.md + +``` +git clone git@github.com/feanil/dotfiles + +ln -sf src/feanil/dotfiles/gitconfig.core ~/.gitconfig +``` Update as necessary for work and personal paths. -Setup Python and Virtualenvwrapper -================================== +Setup ZSH and oh-my-zsh +======================= + +Install zsh - https://github.com/ohmyzsh/ohmyzsh/wiki/Installing-ZSH -Install python3 python3-dev -Install virtualenvwrapper +fzf + +Follow the instructions to install oh-my-zsh: https://ohmyz.sh/#install + +Install powerlevel10k theme https://github.com/romkatv/powerlevel10k#oh-my-zsh + +ln -sf ~/src/feanil/dotfiles/.p10k.zsh .p10k.zsh +ln -sf ~/src/feanil/dotfiles/.zshrc .zshrc tmux ==== Install tmux + +``` ln -sf ~/src/feanil/dotfiles/tmux.conf .tmux.conf +``` + +Setup Python and Virtualenvwrapper +================================== + +Install uv - https://github.com/astral-sh/uv + +Setup python versions + +Install HomeBrew +================ + +https://brew.sh/ + + mcfly @@ -30,17 +62,6 @@ Instructions - https://github.com/cantino/mcfly#installing-using-our-install-scr Release Page - https://github.com/cantino/mcfly/releases - Get the musl version which will be fully statically linked. -Setup ZSH and oh-my-zsh -======================= - -Install zsh fzf -Follow the instructions to install oh-my-zsh: https://ohmyz.sh/#install -Install powerlevel10k theme https://github.com/romkatv/powerlevel10k#oh-my-zsh - -ln -sf ~/src/feanil/dotfiles/.p10k.zsh .p10k.zsh -ln -sf ~/src/feanil/dotfiles/.zshrc .zshrc - - VIM === diff --git a/gitconfig.core b/gitconfig.core index 81b7232..f714bef 100644 --- a/gitconfig.core +++ b/gitconfig.core @@ -1,6 +1,9 @@ -[includeIf "gitdir:~/src/"] +[includeIf "gitdir:~/src/openedx/"] path = src/feanil/dotfiles/gitconfig.work +[includeIf "gitdir:~/src/feanil/"] + path = src/feanil/dotfiles/gitconfig.personal + [includeIf "gitdir:~/.password-store/"] path = src/feanil/dotfiles/gitconfig.personal From fdc9647e454f4f1dac42db178d116165c4ac5720 Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Thu, 17 Apr 2025 18:19:34 -0400 Subject: [PATCH 24/67] fix: Add a new allowed signer and update various git config. --- git_allowed_signers | 1 + gitconfig.core | 6 ------ gitconfig.personal | 4 ++-- 3 files changed, 3 insertions(+), 8 deletions(-) diff --git a/git_allowed_signers b/git_allowed_signers index f4dd731..c642947 100644 --- a/git_allowed_signers +++ b/git_allowed_signers @@ -1,3 +1,4 @@ feanil@axim.org ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDH1ZvaTRhSE6eS7RNwUwFcTUbkK4/WFgO3TaW1S4Gr+9FKkJ8rzBusSCYPbwS6XgEJGnLB2qBQlnK3EvSmljKgVLBeruxiJInz1Gx7184NOvKDmE4+gFe46MThNl7FwA02NwYk0tnLHu9zYL3DjS1um6Txy/K0GEyqliGGq8tcuBwWb8xKlE7/aTEu1zaqH592q4JB7zDUV/iNuxUKU70EAu2gzHmmC7AhewYnVoVKou6YBvlvB5pfVgHgdcHF7EBV2mIidIwQzDDzDyLRg5zxTZ/ABfjhd/2En6o2NYEftOWqSbzfmEInBavvIeqyJExPvUVh7EWs635dZ6JB9NVhNJY0u58xTAItWL3ZNFlmv1ENvY2UKsU5b2NnLFopezuNreouIb6jE6zHqrT1z2jYfMYO3/hAMY3/NyCyvPrJiQd/obP7yDswq1fgXEKt3VJoMgMcEIBt/Yl7RhGwnawAO3HujFk+cAOmFA10zd4j3svRZ9ZMO8tjD5BSkonxE89YvLb5IPCLR4V1l85g3DVF8tHxp+SJWTX2ma8LGGg1/bjvkgAZ2P8w/qEF8Iw0ONgYUuaJvfDsHv9KjoBWXzbhjcnlfA2UFRTuNuGFRKkip6+BKhGcFBE1U5z1H6NufOc2f6rNwGoN/+0+kCcf/YnvCBx4j4c13k3DHelaH6FG2Q== feanil@gmail.com feanil@gmail.com ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDH1ZvaTRhSE6eS7RNwUwFcTUbkK4/WFgO3TaW1S4Gr+9FKkJ8rzBusSCYPbwS6XgEJGnLB2qBQlnK3EvSmljKgVLBeruxiJInz1Gx7184NOvKDmE4+gFe46MThNl7FwA02NwYk0tnLHu9zYL3DjS1um6Txy/K0GEyqliGGq8tcuBwWb8xKlE7/aTEu1zaqH592q4JB7zDUV/iNuxUKU70EAu2gzHmmC7AhewYnVoVKou6YBvlvB5pfVgHgdcHF7EBV2mIidIwQzDDzDyLRg5zxTZ/ABfjhd/2En6o2NYEftOWqSbzfmEInBavvIeqyJExPvUVh7EWs635dZ6JB9NVhNJY0u58xTAItWL3ZNFlmv1ENvY2UKsU5b2NnLFopezuNreouIb6jE6zHqrT1z2jYfMYO3/hAMY3/NyCyvPrJiQd/obP7yDswq1fgXEKt3VJoMgMcEIBt/Yl7RhGwnawAO3HujFk+cAOmFA10zd4j3svRZ9ZMO8tjD5BSkonxE89YvLb5IPCLR4V1l85g3DVF8tHxp+SJWTX2ma8LGGg1/bjvkgAZ2P8w/qEF8Iw0ONgYUuaJvfDsHv9KjoBWXzbhjcnlfA2UFRTuNuGFRKkip6+BKhGcFBE1U5z1H6NufOc2f6rNwGoN/+0+kCcf/YnvCBx4j4c13k3DHelaH6FG2Q== feanil@gmail.com feanil@axim.org ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAxWEVmi5CYIs2zrwPPC9VrVigKz9e37p8WJsSkpGFQp feanil@axim.org +inbox@feanil.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINhnaZF9qPYwJLSbI+IBlv70s9RfVeXcL/giKPzxx69E diff --git a/gitconfig.core b/gitconfig.core index f714bef..c75056b 100644 --- a/gitconfig.core +++ b/gitconfig.core @@ -4,12 +4,6 @@ [includeIf "gitdir:~/src/feanil/"] path = src/feanil/dotfiles/gitconfig.personal -[includeIf "gitdir:~/.password-store/"] - path = src/feanil/dotfiles/gitconfig.personal - -[includeIf "gitdir:~/notes/org-notes/"] - path = src/feanil/dotfiles/gitconfig.personal - [init] defaultBranch = main diff --git a/gitconfig.personal b/gitconfig.personal index 63c4b66..5abfc61 100644 --- a/gitconfig.personal +++ b/gitconfig.personal @@ -1,4 +1,4 @@ [user] - email = feanil@gmail.com + email = github@feanil.com name = Feanil Patel - signingkey = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDH1ZvaTRhSE6eS7RNwUwFcTUbkK4/WFgO3TaW1S4Gr+9FKkJ8rzBusSCYPbwS6XgEJGnLB2qBQlnK3EvSmljKgVLBeruxiJInz1Gx7184NOvKDmE4+gFe46MThNl7FwA02NwYk0tnLHu9zYL3DjS1um6Txy/K0GEyqliGGq8tcuBwWb8xKlE7/aTEu1zaqH592q4JB7zDUV/iNuxUKU70EAu2gzHmmC7AhewYnVoVKou6YBvlvB5pfVgHgdcHF7EBV2mIidIwQzDDzDyLRg5zxTZ/ABfjhd/2En6o2NYEftOWqSbzfmEInBavvIeqyJExPvUVh7EWs635dZ6JB9NVhNJY0u58xTAItWL3ZNFlmv1ENvY2UKsU5b2NnLFopezuNreouIb6jE6zHqrT1z2jYfMYO3/hAMY3/NyCyvPrJiQd/obP7yDswq1fgXEKt3VJoMgMcEIBt/Yl7RhGwnawAO3HujFk+cAOmFA10zd4j3svRZ9ZMO8tjD5BSkonxE89YvLb5IPCLR4V1l85g3DVF8tHxp+SJWTX2ma8LGGg1/bjvkgAZ2P8w/qEF8Iw0ONgYUuaJvfDsHv9KjoBWXzbhjcnlfA2UFRTuNuGFRKkip6+BKhGcFBE1U5z1H6NufOc2f6rNwGoN/+0+kCcf/YnvCBx4j4c13k3DHelaH6FG2Q== feanil@gmail.com" + signingkey = /home/feanil/.ssh/signing_key.pub From 95b0e326bf5a7853bd36088e350d47102a54deb4 Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Thu, 17 Apr 2025 18:20:24 -0400 Subject: [PATCH 25/67] docs: Update readme after new laptop setup. --- .zshrc | 1 - README.md | 41 +++++++++++++++++++++++------------------ 2 files changed, 23 insertions(+), 19 deletions(-) diff --git a/.zshrc b/.zshrc index 9eb7208..2ffe157 100644 --- a/.zshrc +++ b/.zshrc @@ -136,7 +136,6 @@ alias copy=xclip -selection clipboard # To customize prompt, run `p10k configure` or edit ~/.p10k.zsh. [[ -f ~/.p10k.zsh ]] && source ~/.p10k.zsh -alias cat=batcat export MCFLY_INTERFACE_VIEW=BOTTOM export MCFLY_FUZZY=2 eval "$(mcfly init zsh)" diff --git a/README.md b/README.md index aea0e38..e13a660 100644 --- a/README.md +++ b/README.md @@ -24,8 +24,10 @@ Follow the instructions to install oh-my-zsh: https://ohmyz.sh/#install Install powerlevel10k theme https://github.com/romkatv/powerlevel10k#oh-my-zsh -ln -sf ~/src/feanil/dotfiles/.p10k.zsh .p10k.zsh -ln -sf ~/src/feanil/dotfiles/.zshrc .zshrc +``` +ln -sf ~/src/feanil/dotfiles/.p10k.zsh ~/.p10k.zsh +ln -sf ~/src/feanil/dotfiles/.zshrc ~/.zshrc +``` tmux @@ -34,22 +36,24 @@ tmux Install tmux ``` -ln -sf ~/src/feanil/dotfiles/tmux.conf .tmux.conf +ln -sf ~/src/feanil/dotfiles/tmux.conf ~/.tmux.conf ``` -Setup Python and Virtualenvwrapper -================================== - -Install uv - https://github.com/astral-sh/uv - -Setup python versions - Install HomeBrew ================ https://brew.sh/ +``` +brew install mcfly delta uv +``` + +Setup Python and Virtualenvwrapper +================================== + +Install uv - https://github.com/astral-sh/uv +Setup python versions mcfly @@ -62,11 +66,19 @@ Instructions - https://github.com/cantino/mcfly#installing-using-our-install-scr Release Page - https://github.com/cantino/mcfly/releases - Get the musl version which will be fully statically linked. +delta +----- + +- Diffing Tools for git and other diffing. + +Release Page - https://github.com/dandavison/delta/releases +- Get the musl version which will be fully statically linked. + VIM === sudo add-apt-repository ppa:neovim-ppa/stable -Install NeoVim +Install NeoVim - unless you're on debian in which case the default vim is neovim. mkvirtualenv neovim pip install black isort neovim ln -sf ~/src/feanil/dotfiles/config/nvim .config/nvim @@ -110,10 +122,3 @@ jq `sudo apt install jq` -delta ------ - -- Diffing Tools for git and other diffing. - -Release Page - https://github.com/dandavison/delta/releases -- Get the musl version which will be fully statically linked. From 085aa907eef259ded115c40b87c28699df7c8c49 Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Sat, 6 Jun 2026 11:16:18 -0400 Subject: [PATCH 26/67] Drop vim-plug nvim config, point README at LazyVim I've switched to LazyVim, so the in-repo nvim config (init.vim, plug.vim, spell files) is no longer the source of truth. Replaced the VIM section in the README with a pointer to the LazyVim install docs. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 8 +- config/nvim/autoload/plug.vim | 2802 ---------------------------- config/nvim/init.vim | 88 - config/nvim/spell/en.utf-8.add | 14 - config/nvim/spell/en.utf-8.add.spl | Bin 220 -> 0 bytes 5 files changed, 1 insertion(+), 2911 deletions(-) delete mode 100644 config/nvim/autoload/plug.vim delete mode 100644 config/nvim/init.vim delete mode 100644 config/nvim/spell/en.utf-8.add delete mode 100644 config/nvim/spell/en.utf-8.add.spl diff --git a/README.md b/README.md index e13a660..21f62af 100644 --- a/README.md +++ b/README.md @@ -77,13 +77,7 @@ Release Page - https://github.com/dandavison/delta/releases VIM === -sudo add-apt-repository ppa:neovim-ppa/stable -Install NeoVim - unless you're on debian in which case the default vim is neovim. -mkvirtualenv neovim -pip install black isort neovim -ln -sf ~/src/feanil/dotfiles/config/nvim .config/nvim - -Open Vim and run `:PlugInstall` +Install NeoVim (stable) and set up LazyVim: https://www.lazyvim.org/installation For getting the Yubikey working diff --git a/config/nvim/autoload/plug.vim b/config/nvim/autoload/plug.vim deleted file mode 100644 index b6e4cbf..0000000 --- a/config/nvim/autoload/plug.vim +++ /dev/null @@ -1,2802 +0,0 @@ -" vim-plug: Vim plugin manager -" ============================ -" -" Download plug.vim and put it in ~/.vim/autoload -" -" curl -fLo ~/.vim/autoload/plug.vim --create-dirs \ -" https://raw.githubusercontent.com/junegunn/vim-plug/master/plug.vim -" -" Edit your .vimrc -" -" call plug#begin('~/.vim/plugged') -" -" " Make sure you use single quotes -" -" " Shorthand notation; fetches https://github.com/junegunn/vim-easy-align -" Plug 'junegunn/vim-easy-align' -" -" " Any valid git URL is allowed -" Plug 'https://github.com/junegunn/vim-github-dashboard.git' -" -" " Multiple Plug commands can be written in a single line using | separators -" Plug 'SirVer/ultisnips' | Plug 'honza/vim-snippets' -" -" " On-demand loading -" Plug 'scrooloose/nerdtree', { 'on': 'NERDTreeToggle' } -" Plug 'tpope/vim-fireplace', { 'for': 'clojure' } -" -" " Using a non-default branch -" Plug 'rdnetto/YCM-Generator', { 'branch': 'stable' } -" -" " Using a tagged release; wildcard allowed (requires git 1.9.2 or above) -" Plug 'fatih/vim-go', { 'tag': '*' } -" -" " Plugin options -" Plug 'nsf/gocode', { 'tag': 'v.20150303', 'rtp': 'vim' } -" -" " Plugin outside ~/.vim/plugged with post-update hook -" Plug 'junegunn/fzf', { 'dir': '~/.fzf', 'do': './install --all' } -" -" " Unmanaged plugin (manually installed and updated) -" Plug '~/my-prototype-plugin' -" -" " Initialize plugin system -" call plug#end() -" -" Then reload .vimrc and :PlugInstall to install plugins. -" -" Plug options: -" -"| Option | Description | -"| ----------------------- | ------------------------------------------------ | -"| `branch`/`tag`/`commit` | Branch/tag/commit of the repository to use | -"| `rtp` | Subdirectory that contains Vim plugin | -"| `dir` | Custom directory for the plugin | -"| `as` | Use different name for the plugin | -"| `do` | Post-update hook (string or funcref) | -"| `on` | On-demand loading: Commands or ``-mappings | -"| `for` | On-demand loading: File types | -"| `frozen` | Do not update unless explicitly specified | -" -" More information: https://github.com/junegunn/vim-plug -" -" -" Copyright (c) 2017 Junegunn Choi -" -" MIT License -" -" Permission is hereby granted, free of charge, to any person obtaining -" a copy of this software and associated documentation files (the -" "Software"), to deal in the Software without restriction, including -" without limitation the rights to use, copy, modify, merge, publish, -" distribute, sublicense, and/or sell copies of the Software, and to -" permit persons to whom the Software is furnished to do so, subject to -" the following conditions: -" -" The above copyright notice and this permission notice shall be -" included in all copies or substantial portions of the Software. -" -" THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -" EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -" MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -" NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE -" LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -" OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -" WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -if exists('g:loaded_plug') - finish -endif -let g:loaded_plug = 1 - -let s:cpo_save = &cpo -set cpo&vim - -let s:plug_src = 'https://github.com/junegunn/vim-plug.git' -let s:plug_tab = get(s:, 'plug_tab', -1) -let s:plug_buf = get(s:, 'plug_buf', -1) -let s:mac_gui = has('gui_macvim') && has('gui_running') -let s:is_win = has('win32') -let s:nvim = has('nvim-0.2') || (has('nvim') && exists('*jobwait') && !s:is_win) -let s:vim8 = has('patch-8.0.0039') && exists('*job_start') -if s:is_win && &shellslash - set noshellslash - let s:me = resolve(expand(':p')) - set shellslash -else - let s:me = resolve(expand(':p')) -endif -let s:base_spec = { 'branch': '', 'frozen': 0 } -let s:TYPE = { -\ 'string': type(''), -\ 'list': type([]), -\ 'dict': type({}), -\ 'funcref': type(function('call')) -\ } -let s:loaded = get(s:, 'loaded', {}) -let s:triggers = get(s:, 'triggers', {}) - -function! s:is_powershell(shell) - return a:shell =~# 'powershell\(\.exe\)\?$' || a:shell =~# 'pwsh\(\.exe\)\?$' -endfunction - -function! s:isabsolute(dir) abort - return a:dir =~# '^/' || (has('win32') && a:dir =~? '^\%(\\\|[A-Z]:\)') -endfunction - -function! s:git_dir(dir) abort - let gitdir = s:trim(a:dir) . '/.git' - if isdirectory(gitdir) - return gitdir - endif - if !filereadable(gitdir) - return '' - endif - let gitdir = matchstr(get(readfile(gitdir), 0, ''), '^gitdir: \zs.*') - if len(gitdir) && !s:isabsolute(gitdir) - let gitdir = a:dir . '/' . gitdir - endif - return isdirectory(gitdir) ? gitdir : '' -endfunction - -function! s:git_origin_url(dir) abort - let gitdir = s:git_dir(a:dir) - let config = gitdir . '/config' - if empty(gitdir) || !filereadable(config) - return '' - endif - return matchstr(join(readfile(config)), '\[remote "origin"\].\{-}url\s*=\s*\zs\S*\ze') -endfunction - -function! s:git_revision(dir) abort - let gitdir = s:git_dir(a:dir) - let head = gitdir . '/HEAD' - if empty(gitdir) || !filereadable(head) - return '' - endif - - let line = get(readfile(head), 0, '') - let ref = matchstr(line, '^ref: \zs.*') - if empty(ref) - return line - endif - - if filereadable(gitdir . '/' . ref) - return get(readfile(gitdir . '/' . ref), 0, '') - endif - - if filereadable(gitdir . '/packed-refs') - for line in readfile(gitdir . '/packed-refs') - if line =~# ' ' . ref - return matchstr(line, '^[0-9a-f]*') - endif - endfor - endif - - return '' -endfunction - -function! s:git_local_branch(dir) abort - let gitdir = s:git_dir(a:dir) - let head = gitdir . '/HEAD' - if empty(gitdir) || !filereadable(head) - return '' - endif - let branch = matchstr(get(readfile(head), 0, ''), '^ref: refs/heads/\zs.*') - return len(branch) ? branch : 'HEAD' -endfunction - -function! s:git_origin_branch(spec) - if len(a:spec.branch) - return a:spec.branch - endif - - " The file may not be present if this is a local repository - let gitdir = s:git_dir(a:spec.dir) - let origin_head = gitdir.'/refs/remotes/origin/HEAD' - if len(gitdir) && filereadable(origin_head) - return matchstr(get(readfile(origin_head), 0, ''), - \ '^ref: refs/remotes/origin/\zs.*') - endif - - " The command may not return the name of a branch in detached HEAD state - let result = s:lines(s:system('git symbolic-ref --short HEAD', a:spec.dir)) - return v:shell_error ? '' : result[-1] -endfunction - -if s:is_win - function! s:plug_call(fn, ...) - let shellslash = &shellslash - try - set noshellslash - return call(a:fn, a:000) - finally - let &shellslash = shellslash - endtry - endfunction -else - function! s:plug_call(fn, ...) - return call(a:fn, a:000) - endfunction -endif - -function! s:plug_getcwd() - return s:plug_call('getcwd') -endfunction - -function! s:plug_fnamemodify(fname, mods) - return s:plug_call('fnamemodify', a:fname, a:mods) -endfunction - -function! s:plug_expand(fmt) - return s:plug_call('expand', a:fmt, 1) -endfunction - -function! s:plug_tempname() - return s:plug_call('tempname') -endfunction - -function! plug#begin(...) - if a:0 > 0 - let s:plug_home_org = a:1 - let home = s:path(s:plug_fnamemodify(s:plug_expand(a:1), ':p')) - elseif exists('g:plug_home') - let home = s:path(g:plug_home) - elseif !empty(&rtp) - let home = s:path(split(&rtp, ',')[0]) . '/plugged' - else - return s:err('Unable to determine plug home. Try calling plug#begin() with a path argument.') - endif - if s:plug_fnamemodify(home, ':t') ==# 'plugin' && s:plug_fnamemodify(home, ':h') ==# s:first_rtp - return s:err('Invalid plug home. '.home.' is a standard Vim runtime path and is not allowed.') - endif - - let g:plug_home = home - let g:plugs = {} - let g:plugs_order = [] - let s:triggers = {} - - call s:define_commands() - return 1 -endfunction - -function! s:define_commands() - command! -nargs=+ -bar Plug call plug#() - if !executable('git') - return s:err('`git` executable not found. Most commands will not be available. To suppress this message, prepend `silent!` to `call plug#begin(...)`.') - endif - if has('win32') - \ && &shellslash - \ && (&shell =~# 'cmd\(\.exe\)\?$' || s:is_powershell(&shell)) - return s:err('vim-plug does not support shell, ' . &shell . ', when shellslash is set.') - endif - if !has('nvim') - \ && (has('win32') || has('win32unix')) - \ && !has('multi_byte') - return s:err('Vim needs +multi_byte feature on Windows to run shell commands. Enable +iconv for best results.') - endif - command! -nargs=* -bar -bang -complete=customlist,s:names PlugInstall call s:install(0, []) - command! -nargs=* -bar -bang -complete=customlist,s:names PlugUpdate call s:update(0, []) - command! -nargs=0 -bar -bang PlugClean call s:clean(0) - command! -nargs=0 -bar PlugUpgrade if s:upgrade() | execute 'source' s:esc(s:me) | endif - command! -nargs=0 -bar PlugStatus call s:status() - command! -nargs=0 -bar PlugDiff call s:diff() - command! -nargs=? -bar -bang -complete=file PlugSnapshot call s:snapshot(0, ) -endfunction - -function! s:to_a(v) - return type(a:v) == s:TYPE.list ? a:v : [a:v] -endfunction - -function! s:to_s(v) - return type(a:v) == s:TYPE.string ? a:v : join(a:v, "\n") . "\n" -endfunction - -function! s:glob(from, pattern) - return s:lines(globpath(a:from, a:pattern)) -endfunction - -function! s:source(from, ...) - let found = 0 - for pattern in a:000 - for vim in s:glob(a:from, pattern) - execute 'source' s:esc(vim) - let found = 1 - endfor - endfor - return found -endfunction - -function! s:assoc(dict, key, val) - let a:dict[a:key] = add(get(a:dict, a:key, []), a:val) -endfunction - -function! s:ask(message, ...) - call inputsave() - echohl WarningMsg - let answer = input(a:message.(a:0 ? ' (y/N/a) ' : ' (y/N) ')) - echohl None - call inputrestore() - echo "\r" - return (a:0 && answer =~? '^a') ? 2 : (answer =~? '^y') ? 1 : 0 -endfunction - -function! s:ask_no_interrupt(...) - try - return call('s:ask', a:000) - catch - return 0 - endtry -endfunction - -function! s:lazy(plug, opt) - return has_key(a:plug, a:opt) && - \ (empty(s:to_a(a:plug[a:opt])) || - \ !isdirectory(a:plug.dir) || - \ len(s:glob(s:rtp(a:plug), 'plugin')) || - \ len(s:glob(s:rtp(a:plug), 'after/plugin'))) -endfunction - -function! plug#end() - if !exists('g:plugs') - return s:err('plug#end() called without calling plug#begin() first') - endif - - if exists('#PlugLOD') - augroup PlugLOD - autocmd! - augroup END - augroup! PlugLOD - endif - let lod = { 'ft': {}, 'map': {}, 'cmd': {} } - - if exists('g:did_load_filetypes') - filetype off - endif - for name in g:plugs_order - if !has_key(g:plugs, name) - continue - endif - let plug = g:plugs[name] - if get(s:loaded, name, 0) || !s:lazy(plug, 'on') && !s:lazy(plug, 'for') - let s:loaded[name] = 1 - continue - endif - - if has_key(plug, 'on') - let s:triggers[name] = { 'map': [], 'cmd': [] } - for cmd in s:to_a(plug.on) - if cmd =~? '^.\+' - if empty(mapcheck(cmd)) && empty(mapcheck(cmd, 'i')) - call s:assoc(lod.map, cmd, name) - endif - call add(s:triggers[name].map, cmd) - elseif cmd =~# '^[A-Z]' - let cmd = substitute(cmd, '!*$', '', '') - if exists(':'.cmd) != 2 - call s:assoc(lod.cmd, cmd, name) - endif - call add(s:triggers[name].cmd, cmd) - else - call s:err('Invalid `on` option: '.cmd. - \ '. Should start with an uppercase letter or ``.') - endif - endfor - endif - - if has_key(plug, 'for') - let types = s:to_a(plug.for) - if !empty(types) - augroup filetypedetect - call s:source(s:rtp(plug), 'ftdetect/**/*.vim', 'after/ftdetect/**/*.vim') - augroup END - endif - for type in types - call s:assoc(lod.ft, type, name) - endfor - endif - endfor - - for [cmd, names] in items(lod.cmd) - execute printf( - \ 'command! -nargs=* -range -bang -complete=file %s call s:lod_cmd(%s, "", , , , %s)', - \ cmd, string(cmd), string(names)) - endfor - - for [map, names] in items(lod.map) - for [mode, map_prefix, key_prefix] in - \ [['i', '', ''], ['n', '', ''], ['v', '', 'gv'], ['o', '', '']] - execute printf( - \ '%snoremap %s %s:call lod_map(%s, %s, %s, "%s")', - \ mode, map, map_prefix, string(map), string(names), mode != 'i', key_prefix) - endfor - endfor - - for [ft, names] in items(lod.ft) - augroup PlugLOD - execute printf('autocmd FileType %s call lod_ft(%s, %s)', - \ ft, string(ft), string(names)) - augroup END - endfor - - call s:reorg_rtp() - filetype plugin indent on - if has('vim_starting') - if has('syntax') && !exists('g:syntax_on') - syntax enable - end - else - call s:reload_plugins() - endif -endfunction - -function! s:loaded_names() - return filter(copy(g:plugs_order), 'get(s:loaded, v:val, 0)') -endfunction - -function! s:load_plugin(spec) - call s:source(s:rtp(a:spec), 'plugin/**/*.vim', 'after/plugin/**/*.vim') -endfunction - -function! s:reload_plugins() - for name in s:loaded_names() - call s:load_plugin(g:plugs[name]) - endfor -endfunction - -function! s:trim(str) - return substitute(a:str, '[\/]\+$', '', '') -endfunction - -function! s:version_requirement(val, min) - for idx in range(0, len(a:min) - 1) - let v = get(a:val, idx, 0) - if v < a:min[idx] | return 0 - elseif v > a:min[idx] | return 1 - endif - endfor - return 1 -endfunction - -function! s:git_version_requirement(...) - if !exists('s:git_version') - let s:git_version = map(split(split(s:system(['git', '--version']))[2], '\.'), 'str2nr(v:val)') - endif - return s:version_requirement(s:git_version, a:000) -endfunction - -function! s:progress_opt(base) - return a:base && !s:is_win && - \ s:git_version_requirement(1, 7, 1) ? '--progress' : '' -endfunction - -function! s:rtp(spec) - return s:path(a:spec.dir . get(a:spec, 'rtp', '')) -endfunction - -if s:is_win - function! s:path(path) - return s:trim(substitute(a:path, '/', '\', 'g')) - endfunction - - function! s:dirpath(path) - return s:path(a:path) . '\' - endfunction - - function! s:is_local_plug(repo) - return a:repo =~? '^[a-z]:\|^[%~]' - endfunction - - " Copied from fzf - function! s:wrap_cmds(cmds) - let cmds = [ - \ '@echo off', - \ 'setlocal enabledelayedexpansion'] - \ + (type(a:cmds) == type([]) ? a:cmds : [a:cmds]) - \ + ['endlocal'] - if has('iconv') - if !exists('s:codepage') - let s:codepage = libcallnr('kernel32.dll', 'GetACP', 0) - endif - return map(cmds, printf('iconv(v:val."\r", "%s", "cp%d")', &encoding, s:codepage)) - endif - return map(cmds, 'v:val."\r"') - endfunction - - function! s:batchfile(cmd) - let batchfile = s:plug_tempname().'.bat' - call writefile(s:wrap_cmds(a:cmd), batchfile) - let cmd = plug#shellescape(batchfile, {'shell': &shell, 'script': 0}) - if s:is_powershell(&shell) - let cmd = '& ' . cmd - endif - return [batchfile, cmd] - endfunction -else - function! s:path(path) - return s:trim(a:path) - endfunction - - function! s:dirpath(path) - return substitute(a:path, '[/\\]*$', '/', '') - endfunction - - function! s:is_local_plug(repo) - return a:repo[0] =~ '[/$~]' - endfunction -endif - -function! s:err(msg) - echohl ErrorMsg - echom '[vim-plug] '.a:msg - echohl None -endfunction - -function! s:warn(cmd, msg) - echohl WarningMsg - execute a:cmd 'a:msg' - echohl None -endfunction - -function! s:esc(path) - return escape(a:path, ' ') -endfunction - -function! s:escrtp(path) - return escape(a:path, ' ,') -endfunction - -function! s:remove_rtp() - for name in s:loaded_names() - let rtp = s:rtp(g:plugs[name]) - execute 'set rtp-='.s:escrtp(rtp) - let after = globpath(rtp, 'after') - if isdirectory(after) - execute 'set rtp-='.s:escrtp(after) - endif - endfor -endfunction - -function! s:reorg_rtp() - if !empty(s:first_rtp) - execute 'set rtp-='.s:first_rtp - execute 'set rtp-='.s:last_rtp - endif - - " &rtp is modified from outside - if exists('s:prtp') && s:prtp !=# &rtp - call s:remove_rtp() - unlet! s:middle - endif - - let s:middle = get(s:, 'middle', &rtp) - let rtps = map(s:loaded_names(), 's:rtp(g:plugs[v:val])') - let afters = filter(map(copy(rtps), 'globpath(v:val, "after")'), '!empty(v:val)') - let rtp = join(map(rtps, 'escape(v:val, ",")'), ',') - \ . ','.s:middle.',' - \ . join(map(afters, 'escape(v:val, ",")'), ',') - let &rtp = substitute(substitute(rtp, ',,*', ',', 'g'), '^,\|,$', '', 'g') - let s:prtp = &rtp - - if !empty(s:first_rtp) - execute 'set rtp^='.s:first_rtp - execute 'set rtp+='.s:last_rtp - endif -endfunction - -function! s:doautocmd(...) - if exists('#'.join(a:000, '#')) - execute 'doautocmd' ((v:version > 703 || has('patch442')) ? '' : '') join(a:000) - endif -endfunction - -function! s:dobufread(names) - for name in a:names - let path = s:rtp(g:plugs[name]) - for dir in ['ftdetect', 'ftplugin', 'after/ftdetect', 'after/ftplugin'] - if len(finddir(dir, path)) - if exists('#BufRead') - doautocmd BufRead - endif - return - endif - endfor - endfor -endfunction - -function! plug#load(...) - if a:0 == 0 - return s:err('Argument missing: plugin name(s) required') - endif - if !exists('g:plugs') - return s:err('plug#begin was not called') - endif - let names = a:0 == 1 && type(a:1) == s:TYPE.list ? a:1 : a:000 - let unknowns = filter(copy(names), '!has_key(g:plugs, v:val)') - if !empty(unknowns) - let s = len(unknowns) > 1 ? 's' : '' - return s:err(printf('Unknown plugin%s: %s', s, join(unknowns, ', '))) - end - let unloaded = filter(copy(names), '!get(s:loaded, v:val, 0)') - if !empty(unloaded) - for name in unloaded - call s:lod([name], ['ftdetect', 'after/ftdetect', 'plugin', 'after/plugin']) - endfor - call s:dobufread(unloaded) - return 1 - end - return 0 -endfunction - -function! s:remove_triggers(name) - if !has_key(s:triggers, a:name) - return - endif - for cmd in s:triggers[a:name].cmd - execute 'silent! delc' cmd - endfor - for map in s:triggers[a:name].map - execute 'silent! unmap' map - execute 'silent! iunmap' map - endfor - call remove(s:triggers, a:name) -endfunction - -function! s:lod(names, types, ...) - for name in a:names - call s:remove_triggers(name) - let s:loaded[name] = 1 - endfor - call s:reorg_rtp() - - for name in a:names - let rtp = s:rtp(g:plugs[name]) - for dir in a:types - call s:source(rtp, dir.'/**/*.vim') - endfor - if a:0 - if !s:source(rtp, a:1) && !empty(s:glob(rtp, a:2)) - execute 'runtime' a:1 - endif - call s:source(rtp, a:2) - endif - call s:doautocmd('User', name) - endfor -endfunction - -function! s:lod_ft(pat, names) - let syn = 'syntax/'.a:pat.'.vim' - call s:lod(a:names, ['plugin', 'after/plugin'], syn, 'after/'.syn) - execute 'autocmd! PlugLOD FileType' a:pat - call s:doautocmd('filetypeplugin', 'FileType') - call s:doautocmd('filetypeindent', 'FileType') -endfunction - -function! s:lod_cmd(cmd, bang, l1, l2, args, names) - call s:lod(a:names, ['ftdetect', 'after/ftdetect', 'plugin', 'after/plugin']) - call s:dobufread(a:names) - execute printf('%s%s%s %s', (a:l1 == a:l2 ? '' : (a:l1.','.a:l2)), a:cmd, a:bang, a:args) -endfunction - -function! s:lod_map(map, names, with_prefix, prefix) - call s:lod(a:names, ['ftdetect', 'after/ftdetect', 'plugin', 'after/plugin']) - call s:dobufread(a:names) - let extra = '' - while 1 - let c = getchar(0) - if c == 0 - break - endif - let extra .= nr2char(c) - endwhile - - if a:with_prefix - let prefix = v:count ? v:count : '' - let prefix .= '"'.v:register.a:prefix - if mode(1) == 'no' - if v:operator == 'c' - let prefix = "\" . prefix - endif - let prefix .= v:operator - endif - call feedkeys(prefix, 'n') - endif - call feedkeys(substitute(a:map, '^', "\", '') . extra) -endfunction - -function! plug#(repo, ...) - if a:0 > 1 - return s:err('Invalid number of arguments (1..2)') - endif - - try - let repo = s:trim(a:repo) - let opts = a:0 == 1 ? s:parse_options(a:1) : s:base_spec - let name = get(opts, 'as', s:plug_fnamemodify(repo, ':t:s?\.git$??')) - let spec = extend(s:infer_properties(name, repo), opts) - if !has_key(g:plugs, name) - call add(g:plugs_order, name) - endif - let g:plugs[name] = spec - let s:loaded[name] = get(s:loaded, name, 0) - catch - return s:err(repo . ' ' . v:exception) - endtry -endfunction - -function! s:parse_options(arg) - let opts = copy(s:base_spec) - let type = type(a:arg) - let opt_errfmt = 'Invalid argument for "%s" option of :Plug (expected: %s)' - if type == s:TYPE.string - if empty(a:arg) - throw printf(opt_errfmt, 'tag', 'string') - endif - let opts.tag = a:arg - elseif type == s:TYPE.dict - for opt in ['branch', 'tag', 'commit', 'rtp', 'dir', 'as'] - if has_key(a:arg, opt) - \ && (type(a:arg[opt]) != s:TYPE.string || empty(a:arg[opt])) - throw printf(opt_errfmt, opt, 'string') - endif - endfor - for opt in ['on', 'for'] - if has_key(a:arg, opt) - \ && type(a:arg[opt]) != s:TYPE.list - \ && (type(a:arg[opt]) != s:TYPE.string || empty(a:arg[opt])) - throw printf(opt_errfmt, opt, 'string or list') - endif - endfor - if has_key(a:arg, 'do') - \ && type(a:arg.do) != s:TYPE.funcref - \ && (type(a:arg.do) != s:TYPE.string || empty(a:arg.do)) - throw printf(opt_errfmt, 'do', 'string or funcref') - endif - call extend(opts, a:arg) - if has_key(opts, 'dir') - let opts.dir = s:dirpath(s:plug_expand(opts.dir)) - endif - else - throw 'Invalid argument type (expected: string or dictionary)' - endif - return opts -endfunction - -function! s:infer_properties(name, repo) - let repo = a:repo - if s:is_local_plug(repo) - return { 'dir': s:dirpath(s:plug_expand(repo)) } - else - if repo =~ ':' - let uri = repo - else - if repo !~ '/' - throw printf('Invalid argument: %s (implicit `vim-scripts'' expansion is deprecated)', repo) - endif - let fmt = get(g:, 'plug_url_format', 'https://git::@github.com/%s.git') - let uri = printf(fmt, repo) - endif - return { 'dir': s:dirpath(g:plug_home.'/'.a:name), 'uri': uri } - endif -endfunction - -function! s:install(force, names) - call s:update_impl(0, a:force, a:names) -endfunction - -function! s:update(force, names) - call s:update_impl(1, a:force, a:names) -endfunction - -function! plug#helptags() - if !exists('g:plugs') - return s:err('plug#begin was not called') - endif - for spec in values(g:plugs) - let docd = join([s:rtp(spec), 'doc'], '/') - if isdirectory(docd) - silent! execute 'helptags' s:esc(docd) - endif - endfor - return 1 -endfunction - -function! s:syntax() - syntax clear - syntax region plug1 start=/\%1l/ end=/\%2l/ contains=plugNumber - syntax region plug2 start=/\%2l/ end=/\%3l/ contains=plugBracket,plugX - syn match plugNumber /[0-9]\+[0-9.]*/ contained - syn match plugBracket /[[\]]/ contained - syn match plugX /x/ contained - syn match plugDash /^-\{1}\ / - syn match plugPlus /^+/ - syn match plugStar /^*/ - syn match plugMessage /\(^- \)\@<=.*/ - syn match plugName /\(^- \)\@<=[^ ]*:/ - syn match plugSha /\%(: \)\@<=[0-9a-f]\{4,}$/ - syn match plugTag /(tag: [^)]\+)/ - syn match plugInstall /\(^+ \)\@<=[^:]*/ - syn match plugUpdate /\(^* \)\@<=[^:]*/ - syn match plugCommit /^ \X*[0-9a-f]\{7,9} .*/ contains=plugRelDate,plugEdge,plugTag - syn match plugEdge /^ \X\+$/ - syn match plugEdge /^ \X*/ contained nextgroup=plugSha - syn match plugSha /[0-9a-f]\{7,9}/ contained - syn match plugRelDate /([^)]*)$/ contained - syn match plugNotLoaded /(not loaded)$/ - syn match plugError /^x.*/ - syn region plugDeleted start=/^\~ .*/ end=/^\ze\S/ - syn match plugH2 /^.*:\n-\+$/ - syn match plugH2 /^-\{2,}/ - syn keyword Function PlugInstall PlugStatus PlugUpdate PlugClean - hi def link plug1 Title - hi def link plug2 Repeat - hi def link plugH2 Type - hi def link plugX Exception - hi def link plugBracket Structure - hi def link plugNumber Number - - hi def link plugDash Special - hi def link plugPlus Constant - hi def link plugStar Boolean - - hi def link plugMessage Function - hi def link plugName Label - hi def link plugInstall Function - hi def link plugUpdate Type - - hi def link plugError Error - hi def link plugDeleted Ignore - hi def link plugRelDate Comment - hi def link plugEdge PreProc - hi def link plugSha Identifier - hi def link plugTag Constant - - hi def link plugNotLoaded Comment -endfunction - -function! s:lpad(str, len) - return a:str . repeat(' ', a:len - len(a:str)) -endfunction - -function! s:lines(msg) - return split(a:msg, "[\r\n]") -endfunction - -function! s:lastline(msg) - return get(s:lines(a:msg), -1, '') -endfunction - -function! s:new_window() - execute get(g:, 'plug_window', 'vertical topleft new') -endfunction - -function! s:plug_window_exists() - let buflist = tabpagebuflist(s:plug_tab) - return !empty(buflist) && index(buflist, s:plug_buf) >= 0 -endfunction - -function! s:switch_in() - if !s:plug_window_exists() - return 0 - endif - - if winbufnr(0) != s:plug_buf - let s:pos = [tabpagenr(), winnr(), winsaveview()] - execute 'normal!' s:plug_tab.'gt' - let winnr = bufwinnr(s:plug_buf) - execute winnr.'wincmd w' - call add(s:pos, winsaveview()) - else - let s:pos = [winsaveview()] - endif - - setlocal modifiable - return 1 -endfunction - -function! s:switch_out(...) - call winrestview(s:pos[-1]) - setlocal nomodifiable - if a:0 > 0 - execute a:1 - endif - - if len(s:pos) > 1 - execute 'normal!' s:pos[0].'gt' - execute s:pos[1] 'wincmd w' - call winrestview(s:pos[2]) - endif -endfunction - -function! s:finish_bindings() - nnoremap R :call retry() - nnoremap D :PlugDiff - nnoremap S :PlugStatus - nnoremap U :call status_update() - xnoremap U :call status_update() - nnoremap ]] :silent! call section('') - nnoremap [[ :silent! call section('b') -endfunction - -function! s:prepare(...) - if empty(s:plug_getcwd()) - throw 'Invalid current working directory. Cannot proceed.' - endif - - for evar in ['$GIT_DIR', '$GIT_WORK_TREE'] - if exists(evar) - throw evar.' detected. Cannot proceed.' - endif - endfor - - call s:job_abort() - if s:switch_in() - if b:plug_preview == 1 - pc - endif - enew - else - call s:new_window() - endif - - nnoremap q :call close_pane() - if a:0 == 0 - call s:finish_bindings() - endif - let b:plug_preview = -1 - let s:plug_tab = tabpagenr() - let s:plug_buf = winbufnr(0) - call s:assign_name() - - for k in ['', 'L', 'o', 'X', 'd', 'dd'] - execute 'silent! unmap ' k - endfor - setlocal buftype=nofile bufhidden=wipe nobuflisted nolist noswapfile nowrap cursorline modifiable nospell - if exists('+colorcolumn') - setlocal colorcolumn= - endif - setf vim-plug - if exists('g:syntax_on') - call s:syntax() - endif -endfunction - -function! s:close_pane() - if b:plug_preview == 1 - pc - let b:plug_preview = -1 - else - bd - endif -endfunction - -function! s:assign_name() - " Assign buffer name - let prefix = '[Plugins]' - let name = prefix - let idx = 2 - while bufexists(name) - let name = printf('%s (%s)', prefix, idx) - let idx = idx + 1 - endwhile - silent! execute 'f' fnameescape(name) -endfunction - -function! s:chsh(swap) - let prev = [&shell, &shellcmdflag, &shellredir] - if !s:is_win - set shell=sh - endif - if a:swap - if s:is_powershell(&shell) - let &shellredir = '2>&1 | Out-File -Encoding UTF8 %s' - elseif &shell =~# 'sh' || &shell =~# 'cmd\(\.exe\)\?$' - set shellredir=>%s\ 2>&1 - endif - endif - return prev -endfunction - -function! s:bang(cmd, ...) - let batchfile = '' - try - let [sh, shellcmdflag, shrd] = s:chsh(a:0) - " FIXME: Escaping is incomplete. We could use shellescape with eval, - " but it won't work on Windows. - let cmd = a:0 ? s:with_cd(a:cmd, a:1) : a:cmd - if s:is_win - let [batchfile, cmd] = s:batchfile(cmd) - endif - let g:_plug_bang = (s:is_win && has('gui_running') ? 'silent ' : '').'!'.escape(cmd, '#!%') - execute "normal! :execute g:_plug_bang\\" - finally - unlet g:_plug_bang - let [&shell, &shellcmdflag, &shellredir] = [sh, shellcmdflag, shrd] - if s:is_win && filereadable(batchfile) - call delete(batchfile) - endif - endtry - return v:shell_error ? 'Exit status: ' . v:shell_error : '' -endfunction - -function! s:regress_bar() - let bar = substitute(getline(2)[1:-2], '.*\zs=', 'x', '') - call s:progress_bar(2, bar, len(bar)) -endfunction - -function! s:is_updated(dir) - return !empty(s:system_chomp(['git', 'log', '--pretty=format:%h', 'HEAD...HEAD@{1}'], a:dir)) -endfunction - -function! s:do(pull, force, todo) - for [name, spec] in items(a:todo) - if !isdirectory(spec.dir) - continue - endif - let installed = has_key(s:update.new, name) - let updated = installed ? 0 : - \ (a:pull && index(s:update.errors, name) < 0 && s:is_updated(spec.dir)) - if a:force || installed || updated - execute 'cd' s:esc(spec.dir) - call append(3, '- Post-update hook for '. name .' ... ') - let error = '' - let type = type(spec.do) - if type == s:TYPE.string - if spec.do[0] == ':' - if !get(s:loaded, name, 0) - let s:loaded[name] = 1 - call s:reorg_rtp() - endif - call s:load_plugin(spec) - try - execute spec.do[1:] - catch - let error = v:exception - endtry - if !s:plug_window_exists() - cd - - throw 'Warning: vim-plug was terminated by the post-update hook of '.name - endif - else - let error = s:bang(spec.do) - endif - elseif type == s:TYPE.funcref - try - call s:load_plugin(spec) - let status = installed ? 'installed' : (updated ? 'updated' : 'unchanged') - call spec.do({ 'name': name, 'status': status, 'force': a:force }) - catch - let error = v:exception - endtry - else - let error = 'Invalid hook type' - endif - call s:switch_in() - call setline(4, empty(error) ? (getline(4) . 'OK') - \ : ('x' . getline(4)[1:] . error)) - if !empty(error) - call add(s:update.errors, name) - call s:regress_bar() - endif - cd - - endif - endfor -endfunction - -function! s:hash_match(a, b) - return stridx(a:a, a:b) == 0 || stridx(a:b, a:a) == 0 -endfunction - -function! s:checkout(spec) - let sha = a:spec.commit - let output = s:git_revision(a:spec.dir) - if !empty(output) && !s:hash_match(sha, s:lines(output)[0]) - let credential_helper = s:git_version_requirement(2) ? '-c credential.helper= ' : '' - let output = s:system( - \ 'git '.credential_helper.'fetch --depth 999999 && git checkout '.plug#shellescape(sha).' --', a:spec.dir) - endif - return output -endfunction - -function! s:finish(pull) - let new_frozen = len(filter(keys(s:update.new), 'g:plugs[v:val].frozen')) - if new_frozen - let s = new_frozen > 1 ? 's' : '' - call append(3, printf('- Installed %d frozen plugin%s', new_frozen, s)) - endif - call append(3, '- Finishing ... ') | 4 - redraw - call plug#helptags() - call plug#end() - call setline(4, getline(4) . 'Done!') - redraw - let msgs = [] - if !empty(s:update.errors) - call add(msgs, "Press 'R' to retry.") - endif - if a:pull && len(s:update.new) < len(filter(getline(5, '$'), - \ "v:val =~ '^- ' && v:val !~# 'Already up.to.date'")) - call add(msgs, "Press 'D' to see the updated changes.") - endif - echo join(msgs, ' ') - call s:finish_bindings() -endfunction - -function! s:retry() - if empty(s:update.errors) - return - endif - echo - call s:update_impl(s:update.pull, s:update.force, - \ extend(copy(s:update.errors), [s:update.threads])) -endfunction - -function! s:is_managed(name) - return has_key(g:plugs[a:name], 'uri') -endfunction - -function! s:names(...) - return sort(filter(keys(g:plugs), 'stridx(v:val, a:1) == 0 && s:is_managed(v:val)')) -endfunction - -function! s:check_ruby() - silent! ruby require 'thread'; VIM::command("let g:plug_ruby = '#{RUBY_VERSION}'") - if !exists('g:plug_ruby') - redraw! - return s:warn('echom', 'Warning: Ruby interface is broken') - endif - let ruby_version = split(g:plug_ruby, '\.') - unlet g:plug_ruby - return s:version_requirement(ruby_version, [1, 8, 7]) -endfunction - -function! s:update_impl(pull, force, args) abort - let sync = index(a:args, '--sync') >= 0 || has('vim_starting') - let args = filter(copy(a:args), 'v:val != "--sync"') - let threads = (len(args) > 0 && args[-1] =~ '^[1-9][0-9]*$') ? - \ remove(args, -1) : get(g:, 'plug_threads', 16) - - let managed = filter(copy(g:plugs), 's:is_managed(v:key)') - let todo = empty(args) ? filter(managed, '!v:val.frozen || !isdirectory(v:val.dir)') : - \ filter(managed, 'index(args, v:key) >= 0') - - if empty(todo) - return s:warn('echo', 'No plugin to '. (a:pull ? 'update' : 'install')) - endif - - if !s:is_win && s:git_version_requirement(2, 3) - let s:git_terminal_prompt = exists('$GIT_TERMINAL_PROMPT') ? $GIT_TERMINAL_PROMPT : '' - let $GIT_TERMINAL_PROMPT = 0 - for plug in values(todo) - let plug.uri = substitute(plug.uri, - \ '^https://git::@github\.com', 'https://github.com', '') - endfor - endif - - if !isdirectory(g:plug_home) - try - call mkdir(g:plug_home, 'p') - catch - return s:err(printf('Invalid plug directory: %s. '. - \ 'Try to call plug#begin with a valid directory', g:plug_home)) - endtry - endif - - if has('nvim') && !exists('*jobwait') && threads > 1 - call s:warn('echom', '[vim-plug] Update Neovim for parallel installer') - endif - - let use_job = s:nvim || s:vim8 - let python = (has('python') || has('python3')) && !use_job - let ruby = has('ruby') && !use_job && (v:version >= 703 || v:version == 702 && has('patch374')) && !(s:is_win && has('gui_running')) && threads > 1 && s:check_ruby() - - let s:update = { - \ 'start': reltime(), - \ 'all': todo, - \ 'todo': copy(todo), - \ 'errors': [], - \ 'pull': a:pull, - \ 'force': a:force, - \ 'new': {}, - \ 'threads': (python || ruby || use_job) ? min([len(todo), threads]) : 1, - \ 'bar': '', - \ 'fin': 0 - \ } - - call s:prepare(1) - call append(0, ['', '']) - normal! 2G - silent! redraw - - " Set remote name, overriding a possible user git config's clone.defaultRemoteName - let s:clone_opt = ['--origin', 'origin'] - if get(g:, 'plug_shallow', 1) - call extend(s:clone_opt, ['--depth', '1']) - if s:git_version_requirement(1, 7, 10) - call add(s:clone_opt, '--no-single-branch') - endif - endif - - if has('win32unix') || has('wsl') - call extend(s:clone_opt, ['-c', 'core.eol=lf', '-c', 'core.autocrlf=input']) - endif - - let s:submodule_opt = s:git_version_requirement(2, 8) ? ' --jobs='.threads : '' - - " Python version requirement (>= 2.7) - if python && !has('python3') && !ruby && !use_job && s:update.threads > 1 - redir => pyv - silent python import platform; print platform.python_version() - redir END - let python = s:version_requirement( - \ map(split(split(pyv)[0], '\.'), 'str2nr(v:val)'), [2, 6]) - endif - - if (python || ruby) && s:update.threads > 1 - try - let imd = &imd - if s:mac_gui - set noimd - endif - if ruby - call s:update_ruby() - else - call s:update_python() - endif - catch - let lines = getline(4, '$') - let printed = {} - silent! 4,$d _ - for line in lines - let name = s:extract_name(line, '.', '') - if empty(name) || !has_key(printed, name) - call append('$', line) - if !empty(name) - let printed[name] = 1 - if line[0] == 'x' && index(s:update.errors, name) < 0 - call add(s:update.errors, name) - end - endif - endif - endfor - finally - let &imd = imd - call s:update_finish() - endtry - else - call s:update_vim() - while use_job && sync - sleep 100m - if s:update.fin - break - endif - endwhile - endif -endfunction - -function! s:log4(name, msg) - call setline(4, printf('- %s (%s)', a:msg, a:name)) - redraw -endfunction - -function! s:update_finish() - if exists('s:git_terminal_prompt') - let $GIT_TERMINAL_PROMPT = s:git_terminal_prompt - endif - if s:switch_in() - call append(3, '- Updating ...') | 4 - for [name, spec] in items(filter(copy(s:update.all), 'index(s:update.errors, v:key) < 0 && (s:update.force || s:update.pull || has_key(s:update.new, v:key))')) - let [pos, _] = s:logpos(name) - if !pos - continue - endif - if has_key(spec, 'commit') - call s:log4(name, 'Checking out '.spec.commit) - let out = s:checkout(spec) - elseif has_key(spec, 'tag') - let tag = spec.tag - if tag =~ '\*' - let tags = s:lines(s:system('git tag --list '.plug#shellescape(tag).' --sort -version:refname 2>&1', spec.dir)) - if !v:shell_error && !empty(tags) - let tag = tags[0] - call s:log4(name, printf('Latest tag for %s -> %s', spec.tag, tag)) - call append(3, '') - endif - endif - call s:log4(name, 'Checking out '.tag) - let out = s:system('git checkout -q '.plug#shellescape(tag).' -- 2>&1', spec.dir) - else - let branch = s:git_origin_branch(spec) - call s:log4(name, 'Merging origin/'.s:esc(branch)) - let out = s:system('git checkout -q '.plug#shellescape(branch).' -- 2>&1' - \. (has_key(s:update.new, name) ? '' : ('&& git merge --ff-only '.plug#shellescape('origin/'.branch).' 2>&1')), spec.dir) - endif - if !v:shell_error && filereadable(spec.dir.'/.gitmodules') && - \ (s:update.force || has_key(s:update.new, name) || s:is_updated(spec.dir)) - call s:log4(name, 'Updating submodules. This may take a while.') - let out .= s:bang('git submodule update --init --recursive'.s:submodule_opt.' 2>&1', spec.dir) - endif - let msg = s:format_message(v:shell_error ? 'x': '-', name, out) - if v:shell_error - call add(s:update.errors, name) - call s:regress_bar() - silent execute pos 'd _' - call append(4, msg) | 4 - elseif !empty(out) - call setline(pos, msg[0]) - endif - redraw - endfor - silent 4 d _ - try - call s:do(s:update.pull, s:update.force, filter(copy(s:update.all), 'index(s:update.errors, v:key) < 0 && has_key(v:val, "do")')) - catch - call s:warn('echom', v:exception) - call s:warn('echo', '') - return - endtry - call s:finish(s:update.pull) - call setline(1, 'Updated. Elapsed time: ' . split(reltimestr(reltime(s:update.start)))[0] . ' sec.') - call s:switch_out('normal! gg') - endif -endfunction - -function! s:job_abort() - if (!s:nvim && !s:vim8) || !exists('s:jobs') - return - endif - - for [name, j] in items(s:jobs) - if s:nvim - silent! call jobstop(j.jobid) - elseif s:vim8 - silent! call job_stop(j.jobid) - endif - if j.new - call s:rm_rf(g:plugs[name].dir) - endif - endfor - let s:jobs = {} -endfunction - -function! s:last_non_empty_line(lines) - let len = len(a:lines) - for idx in range(len) - let line = a:lines[len-idx-1] - if !empty(line) - return line - endif - endfor - return '' -endfunction - -function! s:job_out_cb(self, data) abort - let self = a:self - let data = remove(self.lines, -1) . a:data - let lines = map(split(data, "\n", 1), 'split(v:val, "\r", 1)[-1]') - call extend(self.lines, lines) - " To reduce the number of buffer updates - let self.tick = get(self, 'tick', -1) + 1 - if !self.running || self.tick % len(s:jobs) == 0 - let bullet = self.running ? (self.new ? '+' : '*') : (self.error ? 'x' : '-') - let result = self.error ? join(self.lines, "\n") : s:last_non_empty_line(self.lines) - call s:log(bullet, self.name, result) - endif -endfunction - -function! s:job_exit_cb(self, data) abort - let a:self.running = 0 - let a:self.error = a:data != 0 - call s:reap(a:self.name) - call s:tick() -endfunction - -function! s:job_cb(fn, job, ch, data) - if !s:plug_window_exists() " plug window closed - return s:job_abort() - endif - call call(a:fn, [a:job, a:data]) -endfunction - -function! s:nvim_cb(job_id, data, event) dict abort - return (a:event == 'stdout' || a:event == 'stderr') ? - \ s:job_cb('s:job_out_cb', self, 0, join(a:data, "\n")) : - \ s:job_cb('s:job_exit_cb', self, 0, a:data) -endfunction - -function! s:spawn(name, cmd, opts) - let job = { 'name': a:name, 'running': 1, 'error': 0, 'lines': [''], - \ 'new': get(a:opts, 'new', 0) } - let s:jobs[a:name] = job - - if s:nvim - if has_key(a:opts, 'dir') - let job.cwd = a:opts.dir - endif - let argv = a:cmd - call extend(job, { - \ 'on_stdout': function('s:nvim_cb'), - \ 'on_stderr': function('s:nvim_cb'), - \ 'on_exit': function('s:nvim_cb'), - \ }) - let jid = s:plug_call('jobstart', argv, job) - if jid > 0 - let job.jobid = jid - else - let job.running = 0 - let job.error = 1 - let job.lines = [jid < 0 ? argv[0].' is not executable' : - \ 'Invalid arguments (or job table is full)'] - endif - elseif s:vim8 - let cmd = join(map(copy(a:cmd), 'plug#shellescape(v:val, {"script": 0})')) - if has_key(a:opts, 'dir') - let cmd = s:with_cd(cmd, a:opts.dir, 0) - endif - let argv = s:is_win ? ['cmd', '/s', '/c', '"'.cmd.'"'] : ['sh', '-c', cmd] - let jid = job_start(s:is_win ? join(argv, ' ') : argv, { - \ 'out_cb': function('s:job_cb', ['s:job_out_cb', job]), - \ 'err_cb': function('s:job_cb', ['s:job_out_cb', job]), - \ 'exit_cb': function('s:job_cb', ['s:job_exit_cb', job]), - \ 'err_mode': 'raw', - \ 'out_mode': 'raw' - \}) - if job_status(jid) == 'run' - let job.jobid = jid - else - let job.running = 0 - let job.error = 1 - let job.lines = ['Failed to start job'] - endif - else - let job.lines = s:lines(call('s:system', has_key(a:opts, 'dir') ? [a:cmd, a:opts.dir] : [a:cmd])) - let job.error = v:shell_error != 0 - let job.running = 0 - endif -endfunction - -function! s:reap(name) - let job = s:jobs[a:name] - if job.error - call add(s:update.errors, a:name) - elseif get(job, 'new', 0) - let s:update.new[a:name] = 1 - endif - let s:update.bar .= job.error ? 'x' : '=' - - let bullet = job.error ? 'x' : '-' - let result = job.error ? join(job.lines, "\n") : s:last_non_empty_line(job.lines) - call s:log(bullet, a:name, empty(result) ? 'OK' : result) - call s:bar() - - call remove(s:jobs, a:name) -endfunction - -function! s:bar() - if s:switch_in() - let total = len(s:update.all) - call setline(1, (s:update.pull ? 'Updating' : 'Installing'). - \ ' plugins ('.len(s:update.bar).'/'.total.')') - call s:progress_bar(2, s:update.bar, total) - call s:switch_out() - endif -endfunction - -function! s:logpos(name) - let max = line('$') - for i in range(4, max > 4 ? max : 4) - if getline(i) =~# '^[-+x*] '.a:name.':' - for j in range(i + 1, max > 5 ? max : 5) - if getline(j) !~ '^ ' - return [i, j - 1] - endif - endfor - return [i, i] - endif - endfor - return [0, 0] -endfunction - -function! s:log(bullet, name, lines) - if s:switch_in() - let [b, e] = s:logpos(a:name) - if b > 0 - silent execute printf('%d,%d d _', b, e) - if b > winheight('.') - let b = 4 - endif - else - let b = 4 - endif - " FIXME For some reason, nomodifiable is set after :d in vim8 - setlocal modifiable - call append(b - 1, s:format_message(a:bullet, a:name, a:lines)) - call s:switch_out() - endif -endfunction - -function! s:update_vim() - let s:jobs = {} - - call s:bar() - call s:tick() -endfunction - -function! s:tick() - let pull = s:update.pull - let prog = s:progress_opt(s:nvim || s:vim8) -while 1 " Without TCO, Vim stack is bound to explode - if empty(s:update.todo) - if empty(s:jobs) && !s:update.fin - call s:update_finish() - let s:update.fin = 1 - endif - return - endif - - let name = keys(s:update.todo)[0] - let spec = remove(s:update.todo, name) - let new = empty(globpath(spec.dir, '.git', 1)) - - call s:log(new ? '+' : '*', name, pull ? 'Updating ...' : 'Installing ...') - redraw - - let has_tag = has_key(spec, 'tag') - if !new - let [error, _] = s:git_validate(spec, 0) - if empty(error) - if pull - let cmd = s:git_version_requirement(2) ? ['git', '-c', 'credential.helper=', 'fetch'] : ['git', 'fetch'] - if has_tag && !empty(globpath(spec.dir, '.git/shallow')) - call extend(cmd, ['--depth', '99999999']) - endif - if !empty(prog) - call add(cmd, prog) - endif - call s:spawn(name, cmd, { 'dir': spec.dir }) - else - let s:jobs[name] = { 'running': 0, 'lines': ['Already installed'], 'error': 0 } - endif - else - let s:jobs[name] = { 'running': 0, 'lines': s:lines(error), 'error': 1 } - endif - else - let cmd = ['git', 'clone'] - if !has_tag - call extend(cmd, s:clone_opt) - endif - if !empty(prog) - call add(cmd, prog) - endif - call s:spawn(name, extend(cmd, [spec.uri, s:trim(spec.dir)]), { 'new': 1 }) - endif - - if !s:jobs[name].running - call s:reap(name) - endif - if len(s:jobs) >= s:update.threads - break - endif -endwhile -endfunction - -function! s:update_python() -let py_exe = has('python') ? 'python' : 'python3' -execute py_exe "<< EOF" -import datetime -import functools -import os -try: - import queue -except ImportError: - import Queue as queue -import random -import re -import shutil -import signal -import subprocess -import tempfile -import threading as thr -import time -import traceback -import vim - -G_NVIM = vim.eval("has('nvim')") == '1' -G_PULL = vim.eval('s:update.pull') == '1' -G_RETRIES = int(vim.eval('get(g:, "plug_retries", 2)')) + 1 -G_TIMEOUT = int(vim.eval('get(g:, "plug_timeout", 60)')) -G_CLONE_OPT = ' '.join(vim.eval('s:clone_opt')) -G_PROGRESS = vim.eval('s:progress_opt(1)') -G_LOG_PROB = 1.0 / int(vim.eval('s:update.threads')) -G_STOP = thr.Event() -G_IS_WIN = vim.eval('s:is_win') == '1' - -class PlugError(Exception): - def __init__(self, msg): - self.msg = msg -class CmdTimedOut(PlugError): - pass -class CmdFailed(PlugError): - pass -class InvalidURI(PlugError): - pass -class Action(object): - INSTALL, UPDATE, ERROR, DONE = ['+', '*', 'x', '-'] - -class Buffer(object): - def __init__(self, lock, num_plugs, is_pull): - self.bar = '' - self.event = 'Updating' if is_pull else 'Installing' - self.lock = lock - self.maxy = int(vim.eval('winheight(".")')) - self.num_plugs = num_plugs - - def __where(self, name): - """ Find first line with name in current buffer. Return line num. """ - found, lnum = False, 0 - matcher = re.compile('^[-+x*] {0}:'.format(name)) - for line in vim.current.buffer: - if matcher.search(line) is not None: - found = True - break - lnum += 1 - - if not found: - lnum = -1 - return lnum - - def header(self): - curbuf = vim.current.buffer - curbuf[0] = self.event + ' plugins ({0}/{1})'.format(len(self.bar), self.num_plugs) - - num_spaces = self.num_plugs - len(self.bar) - curbuf[1] = '[{0}{1}]'.format(self.bar, num_spaces * ' ') - - with self.lock: - vim.command('normal! 2G') - vim.command('redraw') - - def write(self, action, name, lines): - first, rest = lines[0], lines[1:] - msg = ['{0} {1}{2}{3}'.format(action, name, ': ' if first else '', first)] - msg.extend([' ' + line for line in rest]) - - try: - if action == Action.ERROR: - self.bar += 'x' - vim.command("call add(s:update.errors, '{0}')".format(name)) - elif action == Action.DONE: - self.bar += '=' - - curbuf = vim.current.buffer - lnum = self.__where(name) - if lnum != -1: # Found matching line num - del curbuf[lnum] - if lnum > self.maxy and action in set([Action.INSTALL, Action.UPDATE]): - lnum = 3 - else: - lnum = 3 - curbuf.append(msg, lnum) - - self.header() - except vim.error: - pass - -class Command(object): - CD = 'cd /d' if G_IS_WIN else 'cd' - - def __init__(self, cmd, cmd_dir=None, timeout=60, cb=None, clean=None): - self.cmd = cmd - if cmd_dir: - self.cmd = '{0} {1} && {2}'.format(Command.CD, cmd_dir, self.cmd) - self.timeout = timeout - self.callback = cb if cb else (lambda msg: None) - self.clean = clean if clean else (lambda: None) - self.proc = None - - @property - def alive(self): - """ Returns true only if command still running. """ - return self.proc and self.proc.poll() is None - - def execute(self, ntries=3): - """ Execute the command with ntries if CmdTimedOut. - Returns the output of the command if no Exception. - """ - attempt, finished, limit = 0, False, self.timeout - - while not finished: - try: - attempt += 1 - result = self.try_command() - finished = True - return result - except CmdTimedOut: - if attempt != ntries: - self.notify_retry() - self.timeout += limit - else: - raise - - def notify_retry(self): - """ Retry required for command, notify user. """ - for count in range(3, 0, -1): - if G_STOP.is_set(): - raise KeyboardInterrupt - msg = 'Timeout. Will retry in {0} second{1} ...'.format( - count, 's' if count != 1 else '') - self.callback([msg]) - time.sleep(1) - self.callback(['Retrying ...']) - - def try_command(self): - """ Execute a cmd & poll for callback. Returns list of output. - Raises CmdFailed -> return code for Popen isn't 0 - Raises CmdTimedOut -> command exceeded timeout without new output - """ - first_line = True - - try: - tfile = tempfile.NamedTemporaryFile(mode='w+b') - preexec_fn = not G_IS_WIN and os.setsid or None - self.proc = subprocess.Popen(self.cmd, stdout=tfile, - stderr=subprocess.STDOUT, - stdin=subprocess.PIPE, shell=True, - preexec_fn=preexec_fn) - thrd = thr.Thread(target=(lambda proc: proc.wait()), args=(self.proc,)) - thrd.start() - - thread_not_started = True - while thread_not_started: - try: - thrd.join(0.1) - thread_not_started = False - except RuntimeError: - pass - - while self.alive: - if G_STOP.is_set(): - raise KeyboardInterrupt - - if first_line or random.random() < G_LOG_PROB: - first_line = False - line = '' if G_IS_WIN else nonblock_read(tfile.name) - if line: - self.callback([line]) - - time_diff = time.time() - os.path.getmtime(tfile.name) - if time_diff > self.timeout: - raise CmdTimedOut(['Timeout!']) - - thrd.join(0.5) - - tfile.seek(0) - result = [line.decode('utf-8', 'replace').rstrip() for line in tfile] - - if self.proc.returncode != 0: - raise CmdFailed([''] + result) - - return result - except: - self.terminate() - raise - - def terminate(self): - """ Terminate process and cleanup. """ - if self.alive: - if G_IS_WIN: - os.kill(self.proc.pid, signal.SIGINT) - else: - os.killpg(self.proc.pid, signal.SIGTERM) - self.clean() - -class Plugin(object): - def __init__(self, name, args, buf_q, lock): - self.name = name - self.args = args - self.buf_q = buf_q - self.lock = lock - self.tag = args.get('tag', 0) - - def manage(self): - try: - if os.path.exists(self.args['dir']): - self.update() - else: - self.install() - with self.lock: - thread_vim_command("let s:update.new['{0}'] = 1".format(self.name)) - except PlugError as exc: - self.write(Action.ERROR, self.name, exc.msg) - except KeyboardInterrupt: - G_STOP.set() - self.write(Action.ERROR, self.name, ['Interrupted!']) - except: - # Any exception except those above print stack trace - msg = 'Trace:\n{0}'.format(traceback.format_exc().rstrip()) - self.write(Action.ERROR, self.name, msg.split('\n')) - raise - - def install(self): - target = self.args['dir'] - if target[-1] == '\\': - target = target[0:-1] - - def clean(target): - def _clean(): - try: - shutil.rmtree(target) - except OSError: - pass - return _clean - - self.write(Action.INSTALL, self.name, ['Installing ...']) - callback = functools.partial(self.write, Action.INSTALL, self.name) - cmd = 'git clone {0} {1} {2} {3} 2>&1'.format( - '' if self.tag else G_CLONE_OPT, G_PROGRESS, self.args['uri'], - esc(target)) - com = Command(cmd, None, G_TIMEOUT, callback, clean(target)) - result = com.execute(G_RETRIES) - self.write(Action.DONE, self.name, result[-1:]) - - def repo_uri(self): - cmd = 'git rev-parse --abbrev-ref HEAD 2>&1 && git config -f .git/config remote.origin.url' - command = Command(cmd, self.args['dir'], G_TIMEOUT,) - result = command.execute(G_RETRIES) - return result[-1] - - def update(self): - actual_uri = self.repo_uri() - expect_uri = self.args['uri'] - regex = re.compile(r'^(?:\w+://)?(?:[^@/]*@)?([^:/]*(?::[0-9]*)?)[:/](.*?)(?:\.git)?/?$') - ma = regex.match(actual_uri) - mb = regex.match(expect_uri) - if ma is None or mb is None or ma.groups() != mb.groups(): - msg = ['', - 'Invalid URI: {0}'.format(actual_uri), - 'Expected {0}'.format(expect_uri), - 'PlugClean required.'] - raise InvalidURI(msg) - - if G_PULL: - self.write(Action.UPDATE, self.name, ['Updating ...']) - callback = functools.partial(self.write, Action.UPDATE, self.name) - fetch_opt = '--depth 99999999' if self.tag and os.path.isfile(os.path.join(self.args['dir'], '.git/shallow')) else '' - cmd = 'git fetch {0} {1} 2>&1'.format(fetch_opt, G_PROGRESS) - com = Command(cmd, self.args['dir'], G_TIMEOUT, callback) - result = com.execute(G_RETRIES) - self.write(Action.DONE, self.name, result[-1:]) - else: - self.write(Action.DONE, self.name, ['Already installed']) - - def write(self, action, name, msg): - self.buf_q.put((action, name, msg)) - -class PlugThread(thr.Thread): - def __init__(self, tname, args): - super(PlugThread, self).__init__() - self.tname = tname - self.args = args - - def run(self): - thr.current_thread().name = self.tname - buf_q, work_q, lock = self.args - - try: - while not G_STOP.is_set(): - name, args = work_q.get_nowait() - plug = Plugin(name, args, buf_q, lock) - plug.manage() - work_q.task_done() - except queue.Empty: - pass - -class RefreshThread(thr.Thread): - def __init__(self, lock): - super(RefreshThread, self).__init__() - self.lock = lock - self.running = True - - def run(self): - while self.running: - with self.lock: - thread_vim_command('noautocmd normal! a') - time.sleep(0.33) - - def stop(self): - self.running = False - -if G_NVIM: - def thread_vim_command(cmd): - vim.session.threadsafe_call(lambda: vim.command(cmd)) -else: - def thread_vim_command(cmd): - vim.command(cmd) - -def esc(name): - return '"' + name.replace('"', '\"') + '"' - -def nonblock_read(fname): - """ Read a file with nonblock flag. Return the last line. """ - fread = os.open(fname, os.O_RDONLY | os.O_NONBLOCK) - buf = os.read(fread, 100000).decode('utf-8', 'replace') - os.close(fread) - - line = buf.rstrip('\r\n') - left = max(line.rfind('\r'), line.rfind('\n')) - if left != -1: - left += 1 - line = line[left:] - - return line - -def main(): - thr.current_thread().name = 'main' - nthreads = int(vim.eval('s:update.threads')) - plugs = vim.eval('s:update.todo') - mac_gui = vim.eval('s:mac_gui') == '1' - - lock = thr.Lock() - buf = Buffer(lock, len(plugs), G_PULL) - buf_q, work_q = queue.Queue(), queue.Queue() - for work in plugs.items(): - work_q.put(work) - - start_cnt = thr.active_count() - for num in range(nthreads): - tname = 'PlugT-{0:02}'.format(num) - thread = PlugThread(tname, (buf_q, work_q, lock)) - thread.start() - if mac_gui: - rthread = RefreshThread(lock) - rthread.start() - - while not buf_q.empty() or thr.active_count() != start_cnt: - try: - action, name, msg = buf_q.get(True, 0.25) - buf.write(action, name, ['OK'] if not msg else msg) - buf_q.task_done() - except queue.Empty: - pass - except KeyboardInterrupt: - G_STOP.set() - - if mac_gui: - rthread.stop() - rthread.join() - -main() -EOF -endfunction - -function! s:update_ruby() - ruby << EOF - module PlugStream - SEP = ["\r", "\n", nil] - def get_line - buffer = '' - loop do - char = readchar rescue return - if SEP.include? char.chr - buffer << $/ - break - else - buffer << char - end - end - buffer - end - end unless defined?(PlugStream) - - def esc arg - %["#{arg.gsub('"', '\"')}"] - end - - def killall pid - pids = [pid] - if /mswin|mingw|bccwin/ =~ RUBY_PLATFORM - pids.each { |pid| Process.kill 'INT', pid.to_i rescue nil } - else - unless `which pgrep 2> /dev/null`.empty? - children = pids - until children.empty? - children = children.map { |pid| - `pgrep -P #{pid}`.lines.map { |l| l.chomp } - }.flatten - pids += children - end - end - pids.each { |pid| Process.kill 'TERM', pid.to_i rescue nil } - end - end - - def compare_git_uri a, b - regex = %r{^(?:\w+://)?(?:[^@/]*@)?([^:/]*(?::[0-9]*)?)[:/](.*?)(?:\.git)?/?$} - regex.match(a).to_a.drop(1) == regex.match(b).to_a.drop(1) - end - - require 'thread' - require 'fileutils' - require 'timeout' - running = true - iswin = VIM::evaluate('s:is_win').to_i == 1 - pull = VIM::evaluate('s:update.pull').to_i == 1 - base = VIM::evaluate('g:plug_home') - all = VIM::evaluate('s:update.todo') - limit = VIM::evaluate('get(g:, "plug_timeout", 60)') - tries = VIM::evaluate('get(g:, "plug_retries", 2)') + 1 - nthr = VIM::evaluate('s:update.threads').to_i - maxy = VIM::evaluate('winheight(".")').to_i - vim7 = VIM::evaluate('v:version').to_i <= 703 && RUBY_PLATFORM =~ /darwin/ - cd = iswin ? 'cd /d' : 'cd' - tot = VIM::evaluate('len(s:update.todo)') || 0 - bar = '' - skip = 'Already installed' - mtx = Mutex.new - take1 = proc { mtx.synchronize { running && all.shift } } - logh = proc { - cnt = bar.length - $curbuf[1] = "#{pull ? 'Updating' : 'Installing'} plugins (#{cnt}/#{tot})" - $curbuf[2] = '[' + bar.ljust(tot) + ']' - VIM::command('normal! 2G') - VIM::command('redraw') - } - where = proc { |name| (1..($curbuf.length)).find { |l| $curbuf[l] =~ /^[-+x*] #{name}:/ } } - log = proc { |name, result, type| - mtx.synchronize do - ing = ![true, false].include?(type) - bar += type ? '=' : 'x' unless ing - b = case type - when :install then '+' when :update then '*' - when true, nil then '-' else - VIM::command("call add(s:update.errors, '#{name}')") - 'x' - end - result = - if type || type.nil? - ["#{b} #{name}: #{result.lines.to_a.last || 'OK'}"] - elsif result =~ /^Interrupted|^Timeout/ - ["#{b} #{name}: #{result}"] - else - ["#{b} #{name}"] + result.lines.map { |l| " " << l } - end - if lnum = where.call(name) - $curbuf.delete lnum - lnum = 4 if ing && lnum > maxy - end - result.each_with_index do |line, offset| - $curbuf.append((lnum || 4) - 1 + offset, line.gsub(/\e\[./, '').chomp) - end - logh.call - end - } - bt = proc { |cmd, name, type, cleanup| - tried = timeout = 0 - begin - tried += 1 - timeout += limit - fd = nil - data = '' - if iswin - Timeout::timeout(timeout) do - tmp = VIM::evaluate('tempname()') - system("(#{cmd}) > #{tmp}") - data = File.read(tmp).chomp - File.unlink tmp rescue nil - end - else - fd = IO.popen(cmd).extend(PlugStream) - first_line = true - log_prob = 1.0 / nthr - while line = Timeout::timeout(timeout) { fd.get_line } - data << line - log.call name, line.chomp, type if name && (first_line || rand < log_prob) - first_line = false - end - fd.close - end - [$? == 0, data.chomp] - rescue Timeout::Error, Interrupt => e - if fd && !fd.closed? - killall fd.pid - fd.close - end - cleanup.call if cleanup - if e.is_a?(Timeout::Error) && tried < tries - 3.downto(1) do |countdown| - s = countdown > 1 ? 's' : '' - log.call name, "Timeout. Will retry in #{countdown} second#{s} ...", type - sleep 1 - end - log.call name, 'Retrying ...', type - retry - end - [false, e.is_a?(Interrupt) ? "Interrupted!" : "Timeout!"] - end - } - main = Thread.current - threads = [] - watcher = Thread.new { - if vim7 - while VIM::evaluate('getchar(1)') - sleep 0.1 - end - else - require 'io/console' # >= Ruby 1.9 - nil until IO.console.getch == 3.chr - end - mtx.synchronize do - running = false - threads.each { |t| t.raise Interrupt } unless vim7 - end - threads.each { |t| t.join rescue nil } - main.kill - } - refresh = Thread.new { - while true - mtx.synchronize do - break unless running - VIM::command('noautocmd normal! a') - end - sleep 0.2 - end - } if VIM::evaluate('s:mac_gui') == 1 - - clone_opt = VIM::evaluate('s:clone_opt').join(' ') - progress = VIM::evaluate('s:progress_opt(1)') - nthr.times do - mtx.synchronize do - threads << Thread.new { - while pair = take1.call - name = pair.first - dir, uri, tag = pair.last.values_at *%w[dir uri tag] - exists = File.directory? dir - ok, result = - if exists - chdir = "#{cd} #{iswin ? dir : esc(dir)}" - ret, data = bt.call "#{chdir} && git rev-parse --abbrev-ref HEAD 2>&1 && git config -f .git/config remote.origin.url", nil, nil, nil - current_uri = data.lines.to_a.last - if !ret - if data =~ /^Interrupted|^Timeout/ - [false, data] - else - [false, [data.chomp, "PlugClean required."].join($/)] - end - elsif !compare_git_uri(current_uri, uri) - [false, ["Invalid URI: #{current_uri}", - "Expected: #{uri}", - "PlugClean required."].join($/)] - else - if pull - log.call name, 'Updating ...', :update - fetch_opt = (tag && File.exist?(File.join(dir, '.git/shallow'))) ? '--depth 99999999' : '' - bt.call "#{chdir} && git fetch #{fetch_opt} #{progress} 2>&1", name, :update, nil - else - [true, skip] - end - end - else - d = esc dir.sub(%r{[\\/]+$}, '') - log.call name, 'Installing ...', :install - bt.call "git clone #{clone_opt unless tag} #{progress} #{uri} #{d} 2>&1", name, :install, proc { - FileUtils.rm_rf dir - } - end - mtx.synchronize { VIM::command("let s:update.new['#{name}'] = 1") } if !exists && ok - log.call name, result, ok - end - } if running - end - end - threads.each { |t| t.join rescue nil } - logh.call - refresh.kill if refresh - watcher.kill -EOF -endfunction - -function! s:shellesc_cmd(arg, script) - let escaped = substitute('"'.a:arg.'"', '[&|<>()@^!"]', '^&', 'g') - return substitute(escaped, '%', (a:script ? '%' : '^') . '&', 'g') -endfunction - -function! s:shellesc_ps1(arg) - return "'".substitute(escape(a:arg, '\"'), "'", "''", 'g')."'" -endfunction - -function! s:shellesc_sh(arg) - return "'".substitute(a:arg, "'", "'\\\\''", 'g')."'" -endfunction - -" Escape the shell argument based on the shell. -" Vim and Neovim's shellescape() are insufficient. -" 1. shellslash determines whether to use single/double quotes. -" Double-quote escaping is fragile for cmd.exe. -" 2. It does not work for powershell. -" 3. It does not work for *sh shells if the command is executed -" via cmd.exe (ie. cmd.exe /c sh -c command command_args) -" 4. It does not support batchfile syntax. -" -" Accepts an optional dictionary with the following keys: -" - shell: same as Vim/Neovim 'shell' option. -" If unset, fallback to 'cmd.exe' on Windows or 'sh'. -" - script: If truthy and shell is cmd.exe, escape for batchfile syntax. -function! plug#shellescape(arg, ...) - if a:arg =~# '^[A-Za-z0-9_/:.-]\+$' - return a:arg - endif - let opts = a:0 > 0 && type(a:1) == s:TYPE.dict ? a:1 : {} - let shell = get(opts, 'shell', s:is_win ? 'cmd.exe' : 'sh') - let script = get(opts, 'script', 1) - if shell =~# 'cmd\(\.exe\)\?$' - return s:shellesc_cmd(a:arg, script) - elseif s:is_powershell(shell) - return s:shellesc_ps1(a:arg) - endif - return s:shellesc_sh(a:arg) -endfunction - -function! s:glob_dir(path) - return map(filter(s:glob(a:path, '**'), 'isdirectory(v:val)'), 's:dirpath(v:val)') -endfunction - -function! s:progress_bar(line, bar, total) - call setline(a:line, '[' . s:lpad(a:bar, a:total) . ']') -endfunction - -function! s:compare_git_uri(a, b) - " See `git help clone' - " https:// [user@] github.com[:port] / junegunn/vim-plug [.git] - " [git@] github.com[:port] : junegunn/vim-plug [.git] - " file:// / junegunn/vim-plug [/] - " / junegunn/vim-plug [/] - let pat = '^\%(\w\+://\)\='.'\%([^@/]*@\)\='.'\([^:/]*\%(:[0-9]*\)\=\)'.'[:/]'.'\(.\{-}\)'.'\%(\.git\)\=/\?$' - let ma = matchlist(a:a, pat) - let mb = matchlist(a:b, pat) - return ma[1:2] ==# mb[1:2] -endfunction - -function! s:format_message(bullet, name, message) - if a:bullet != 'x' - return [printf('%s %s: %s', a:bullet, a:name, s:lastline(a:message))] - else - let lines = map(s:lines(a:message), '" ".v:val') - return extend([printf('x %s:', a:name)], lines) - endif -endfunction - -function! s:with_cd(cmd, dir, ...) - let script = a:0 > 0 ? a:1 : 1 - return printf('cd%s %s && %s', s:is_win ? ' /d' : '', plug#shellescape(a:dir, {'script': script}), a:cmd) -endfunction - -function! s:system(cmd, ...) - let batchfile = '' - try - let [sh, shellcmdflag, shrd] = s:chsh(1) - if type(a:cmd) == s:TYPE.list - " Neovim's system() supports list argument to bypass the shell - " but it cannot set the working directory for the command. - " Assume that the command does not rely on the shell. - if has('nvim') && a:0 == 0 - return system(a:cmd) - endif - let cmd = join(map(copy(a:cmd), 'plug#shellescape(v:val, {"shell": &shell, "script": 0})')) - if s:is_powershell(&shell) - let cmd = '& ' . cmd - endif - else - let cmd = a:cmd - endif - if a:0 > 0 - let cmd = s:with_cd(cmd, a:1, type(a:cmd) != s:TYPE.list) - endif - if s:is_win && type(a:cmd) != s:TYPE.list - let [batchfile, cmd] = s:batchfile(cmd) - endif - return system(cmd) - finally - let [&shell, &shellcmdflag, &shellredir] = [sh, shellcmdflag, shrd] - if s:is_win && filereadable(batchfile) - call delete(batchfile) - endif - endtry -endfunction - -function! s:system_chomp(...) - let ret = call('s:system', a:000) - return v:shell_error ? '' : substitute(ret, '\n$', '', '') -endfunction - -function! s:git_validate(spec, check_branch) - let err = '' - if isdirectory(a:spec.dir) - let result = [s:git_local_branch(a:spec.dir), s:git_origin_url(a:spec.dir)] - let remote = result[-1] - if empty(remote) - let err = join([remote, 'PlugClean required.'], "\n") - elseif !s:compare_git_uri(remote, a:spec.uri) - let err = join(['Invalid URI: '.remote, - \ 'Expected: '.a:spec.uri, - \ 'PlugClean required.'], "\n") - elseif a:check_branch && has_key(a:spec, 'commit') - let sha = s:git_revision(a:spec.dir) - if empty(sha) - let err = join(add(result, 'PlugClean required.'), "\n") - elseif !s:hash_match(sha, a:spec.commit) - let err = join([printf('Invalid HEAD (expected: %s, actual: %s)', - \ a:spec.commit[:6], sha[:6]), - \ 'PlugUpdate required.'], "\n") - endif - elseif a:check_branch - let current_branch = result[0] - " Check tag - let origin_branch = s:git_origin_branch(a:spec) - if has_key(a:spec, 'tag') - let tag = s:system_chomp('git describe --exact-match --tags HEAD 2>&1', a:spec.dir) - if a:spec.tag !=# tag && a:spec.tag !~ '\*' - let err = printf('Invalid tag: %s (expected: %s). Try PlugUpdate.', - \ (empty(tag) ? 'N/A' : tag), a:spec.tag) - endif - " Check branch - elseif origin_branch !=# current_branch - let err = printf('Invalid branch: %s (expected: %s). Try PlugUpdate.', - \ current_branch, origin_branch) - endif - if empty(err) - let [ahead, behind] = split(s:lastline(s:system([ - \ 'git', 'rev-list', '--count', '--left-right', - \ printf('HEAD...origin/%s', origin_branch) - \ ], a:spec.dir)), '\t') - if !v:shell_error && ahead - if behind - " Only mention PlugClean if diverged, otherwise it's likely to be - " pushable (and probably not that messed up). - let err = printf( - \ "Diverged from origin/%s (%d commit(s) ahead and %d commit(s) behind!\n" - \ .'Backup local changes and run PlugClean and PlugUpdate to reinstall it.', origin_branch, ahead, behind) - else - let err = printf("Ahead of origin/%s by %d commit(s).\n" - \ .'Cannot update until local changes are pushed.', - \ origin_branch, ahead) - endif - endif - endif - endif - else - let err = 'Not found' - endif - return [err, err =~# 'PlugClean'] -endfunction - -function! s:rm_rf(dir) - if isdirectory(a:dir) - return s:system(s:is_win - \ ? 'rmdir /S /Q '.plug#shellescape(a:dir) - \ : ['rm', '-rf', a:dir]) - endif -endfunction - -function! s:clean(force) - call s:prepare() - call append(0, 'Searching for invalid plugins in '.g:plug_home) - call append(1, '') - - " List of valid directories - let dirs = [] - let errs = {} - let [cnt, total] = [0, len(g:plugs)] - for [name, spec] in items(g:plugs) - if !s:is_managed(name) - call add(dirs, spec.dir) - else - let [err, clean] = s:git_validate(spec, 1) - if clean - let errs[spec.dir] = s:lines(err)[0] - else - call add(dirs, spec.dir) - endif - endif - let cnt += 1 - call s:progress_bar(2, repeat('=', cnt), total) - normal! 2G - redraw - endfor - - let allowed = {} - for dir in dirs - let allowed[s:dirpath(s:plug_fnamemodify(dir, ':h:h'))] = 1 - let allowed[dir] = 1 - for child in s:glob_dir(dir) - let allowed[child] = 1 - endfor - endfor - - let todo = [] - let found = sort(s:glob_dir(g:plug_home)) - while !empty(found) - let f = remove(found, 0) - if !has_key(allowed, f) && isdirectory(f) - call add(todo, f) - call append(line('$'), '- ' . f) - if has_key(errs, f) - call append(line('$'), ' ' . errs[f]) - endif - let found = filter(found, 'stridx(v:val, f) != 0') - end - endwhile - - 4 - redraw - if empty(todo) - call append(line('$'), 'Already clean.') - else - let s:clean_count = 0 - call append(3, ['Directories to delete:', '']) - redraw! - if a:force || s:ask_no_interrupt('Delete all directories?') - call s:delete([6, line('$')], 1) - else - call setline(4, 'Cancelled.') - nnoremap d :set opfunc=delete_opg@ - nmap dd d_ - xnoremap d :call delete_op(visualmode(), 1) - echo 'Delete the lines (d{motion}) to delete the corresponding directories' - endif - endif - 4 - setlocal nomodifiable -endfunction - -function! s:delete_op(type, ...) - call s:delete(a:0 ? [line("'<"), line("'>")] : [line("'["), line("']")], 0) -endfunction - -function! s:delete(range, force) - let [l1, l2] = a:range - let force = a:force - let err_count = 0 - while l1 <= l2 - let line = getline(l1) - if line =~ '^- ' && isdirectory(line[2:]) - execute l1 - redraw! - let answer = force ? 1 : s:ask('Delete '.line[2:].'?', 1) - let force = force || answer > 1 - if answer - let err = s:rm_rf(line[2:]) - setlocal modifiable - if empty(err) - call setline(l1, '~'.line[1:]) - let s:clean_count += 1 - else - delete _ - call append(l1 - 1, s:format_message('x', line[1:], err)) - let l2 += len(s:lines(err)) - let err_count += 1 - endif - let msg = printf('Removed %d directories.', s:clean_count) - if err_count > 0 - let msg .= printf(' Failed to remove %d directories.', err_count) - endif - call setline(4, msg) - setlocal nomodifiable - endif - endif - let l1 += 1 - endwhile -endfunction - -function! s:upgrade() - echo 'Downloading the latest version of vim-plug' - redraw - let tmp = s:plug_tempname() - let new = tmp . '/plug.vim' - - try - let out = s:system(['git', 'clone', '--depth', '1', s:plug_src, tmp]) - if v:shell_error - return s:err('Error upgrading vim-plug: '. out) - endif - - if readfile(s:me) ==# readfile(new) - echo 'vim-plug is already up-to-date' - return 0 - else - call rename(s:me, s:me . '.old') - call rename(new, s:me) - unlet g:loaded_plug - echo 'vim-plug has been upgraded' - return 1 - endif - finally - silent! call s:rm_rf(tmp) - endtry -endfunction - -function! s:upgrade_specs() - for spec in values(g:plugs) - let spec.frozen = get(spec, 'frozen', 0) - endfor -endfunction - -function! s:status() - call s:prepare() - call append(0, 'Checking plugins') - call append(1, '') - - let ecnt = 0 - let unloaded = 0 - let [cnt, total] = [0, len(g:plugs)] - for [name, spec] in items(g:plugs) - let is_dir = isdirectory(spec.dir) - if has_key(spec, 'uri') - if is_dir - let [err, _] = s:git_validate(spec, 1) - let [valid, msg] = [empty(err), empty(err) ? 'OK' : err] - else - let [valid, msg] = [0, 'Not found. Try PlugInstall.'] - endif - else - if is_dir - let [valid, msg] = [1, 'OK'] - else - let [valid, msg] = [0, 'Not found.'] - endif - endif - let cnt += 1 - let ecnt += !valid - " `s:loaded` entry can be missing if PlugUpgraded - if is_dir && get(s:loaded, name, -1) == 0 - let unloaded = 1 - let msg .= ' (not loaded)' - endif - call s:progress_bar(2, repeat('=', cnt), total) - call append(3, s:format_message(valid ? '-' : 'x', name, msg)) - normal! 2G - redraw - endfor - call setline(1, 'Finished. '.ecnt.' error(s).') - normal! gg - setlocal nomodifiable - if unloaded - echo "Press 'L' on each line to load plugin, or 'U' to update" - nnoremap L :call status_load(line('.')) - xnoremap L :call status_load(line('.')) - end -endfunction - -function! s:extract_name(str, prefix, suffix) - return matchstr(a:str, '^'.a:prefix.' \zs[^:]\+\ze:.*'.a:suffix.'$') -endfunction - -function! s:status_load(lnum) - let line = getline(a:lnum) - let name = s:extract_name(line, '-', '(not loaded)') - if !empty(name) - call plug#load(name) - setlocal modifiable - call setline(a:lnum, substitute(line, ' (not loaded)$', '', '')) - setlocal nomodifiable - endif -endfunction - -function! s:status_update() range - let lines = getline(a:firstline, a:lastline) - let names = filter(map(lines, 's:extract_name(v:val, "[x-]", "")'), '!empty(v:val)') - if !empty(names) - echo - execute 'PlugUpdate' join(names) - endif -endfunction - -function! s:is_preview_window_open() - silent! wincmd P - if &previewwindow - wincmd p - return 1 - endif -endfunction - -function! s:find_name(lnum) - for lnum in reverse(range(1, a:lnum)) - let line = getline(lnum) - if empty(line) - return '' - endif - let name = s:extract_name(line, '-', '') - if !empty(name) - return name - endif - endfor - return '' -endfunction - -function! s:preview_commit() - if b:plug_preview < 0 - let b:plug_preview = !s:is_preview_window_open() - endif - - let sha = matchstr(getline('.'), '^ \X*\zs[0-9a-f]\{7,9}') - if empty(sha) - return - endif - - let name = s:find_name(line('.')) - if empty(name) || !has_key(g:plugs, name) || !isdirectory(g:plugs[name].dir) - return - endif - - if exists('g:plug_pwindow') && !s:is_preview_window_open() - execute g:plug_pwindow - execute 'e' sha - else - execute 'pedit' sha - wincmd P - endif - setlocal previewwindow filetype=git buftype=nofile nobuflisted modifiable - let batchfile = '' - try - let [sh, shellcmdflag, shrd] = s:chsh(1) - let cmd = 'cd '.plug#shellescape(g:plugs[name].dir).' && git show --no-color --pretty=medium '.sha - if s:is_win - let [batchfile, cmd] = s:batchfile(cmd) - endif - execute 'silent %!' cmd - finally - let [&shell, &shellcmdflag, &shellredir] = [sh, shellcmdflag, shrd] - if s:is_win && filereadable(batchfile) - call delete(batchfile) - endif - endtry - setlocal nomodifiable - nnoremap q :q - wincmd p -endfunction - -function! s:section(flags) - call search('\(^[x-] \)\@<=[^:]\+:', a:flags) -endfunction - -function! s:format_git_log(line) - let indent = ' ' - let tokens = split(a:line, nr2char(1)) - if len(tokens) != 5 - return indent.substitute(a:line, '\s*$', '', '') - endif - let [graph, sha, refs, subject, date] = tokens - let tag = matchstr(refs, 'tag: [^,)]\+') - let tag = empty(tag) ? ' ' : ' ('.tag.') ' - return printf('%s%s%s%s%s (%s)', indent, graph, sha, tag, subject, date) -endfunction - -function! s:append_ul(lnum, text) - call append(a:lnum, ['', a:text, repeat('-', len(a:text))]) -endfunction - -function! s:diff() - call s:prepare() - call append(0, ['Collecting changes ...', '']) - let cnts = [0, 0] - let bar = '' - let total = filter(copy(g:plugs), 's:is_managed(v:key) && isdirectory(v:val.dir)') - call s:progress_bar(2, bar, len(total)) - for origin in [1, 0] - let plugs = reverse(sort(items(filter(copy(total), (origin ? '' : '!').'(has_key(v:val, "commit") || has_key(v:val, "tag"))')))) - if empty(plugs) - continue - endif - call s:append_ul(2, origin ? 'Pending updates:' : 'Last update:') - for [k, v] in plugs - let branch = s:git_origin_branch(v) - if len(branch) - let range = origin ? '..origin/'.branch : 'HEAD@{1}..' - let cmd = ['git', 'log', '--graph', '--color=never'] - if s:git_version_requirement(2, 10, 0) - call add(cmd, '--no-show-signature') - endif - call extend(cmd, ['--pretty=format:%x01%h%x01%d%x01%s%x01%cr', range]) - if has_key(v, 'rtp') - call extend(cmd, ['--', v.rtp]) - endif - let diff = s:system_chomp(cmd, v.dir) - if !empty(diff) - let ref = has_key(v, 'tag') ? (' (tag: '.v.tag.')') : has_key(v, 'commit') ? (' '.v.commit) : '' - call append(5, extend(['', '- '.k.':'.ref], map(s:lines(diff), 's:format_git_log(v:val)'))) - let cnts[origin] += 1 - endif - endif - let bar .= '=' - call s:progress_bar(2, bar, len(total)) - normal! 2G - redraw - endfor - if !cnts[origin] - call append(5, ['', 'N/A']) - endif - endfor - call setline(1, printf('%d plugin(s) updated.', cnts[0]) - \ . (cnts[1] ? printf(' %d plugin(s) have pending updates.', cnts[1]) : '')) - - if cnts[0] || cnts[1] - nnoremap (plug-preview) :silent! call preview_commit() - if empty(maparg("\", 'n')) - nmap (plug-preview) - endif - if empty(maparg('o', 'n')) - nmap o (plug-preview) - endif - endif - if cnts[0] - nnoremap X :call revert() - echo "Press 'X' on each block to revert the update" - endif - normal! gg - setlocal nomodifiable -endfunction - -function! s:revert() - if search('^Pending updates', 'bnW') - return - endif - - let name = s:find_name(line('.')) - if empty(name) || !has_key(g:plugs, name) || - \ input(printf('Revert the update of %s? (y/N) ', name)) !~? '^y' - return - endif - - call s:system('git reset --hard HEAD@{1} && git checkout '.plug#shellescape(g:plugs[name].branch).' --', g:plugs[name].dir) - setlocal modifiable - normal! "_dap - setlocal nomodifiable - echo 'Reverted' -endfunction - -function! s:snapshot(force, ...) abort - call s:prepare() - setf vim - call append(0, ['" Generated by vim-plug', - \ '" '.strftime("%c"), - \ '" :source this file in vim to restore the snapshot', - \ '" or execute: vim -S snapshot.vim', - \ '', '', 'PlugUpdate!']) - 1 - let anchor = line('$') - 3 - let names = sort(keys(filter(copy(g:plugs), - \'has_key(v:val, "uri") && !has_key(v:val, "commit") && isdirectory(v:val.dir)'))) - for name in reverse(names) - let sha = s:git_revision(g:plugs[name].dir) - if !empty(sha) - call append(anchor, printf("silent! let g:plugs['%s'].commit = '%s'", name, sha)) - redraw - endif - endfor - - if a:0 > 0 - let fn = s:plug_expand(a:1) - if filereadable(fn) && !(a:force || s:ask(a:1.' already exists. Overwrite?')) - return - endif - call writefile(getline(1, '$'), fn) - echo 'Saved as '.a:1 - silent execute 'e' s:esc(fn) - setf vim - endif -endfunction - -function! s:split_rtp() - return split(&rtp, '\\\@ :NERDTreeFind -nmap :FZF diff --git a/config/nvim/spell/en.utf-8.add b/config/nvim/spell/en.utf-8.add deleted file mode 100644 index 43a65ed..0000000 --- a/config/nvim/spell/en.utf-8.add +++ /dev/null @@ -1,14 +0,0 @@ -PRs -edX -backend -frontend -MFEs -FrontEnds -edx -LMS -frontends -backends -READMEs -Django -XBlocks -XBlock diff --git a/config/nvim/spell/en.utf-8.add.spl b/config/nvim/spell/en.utf-8.add.spl deleted file mode 100644 index f067fb68f3d589aca567e52a54ededd2ecc70333..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 220 zcmX|)u?oUK5JYzt5&Qs2;V0Nx*(zQnNI+6Z+QhpEdWj)tkura-6U8*k?Ax80OOl4x zdB1s!h+UM{RlaV!9tEy=z@dRv2@u?g8r<-NDlB>x9nf)Y@cREjWF1f=Kxe2(DF%=x z9hZVO>SK)(o< Date: Sat, 6 Jun 2026 11:16:46 -0400 Subject: [PATCH 27/67] xmonad: use kitty as the terminal and re-enable focused border color Mod+Shift+Return now launches kitty instead of xfce4-terminal, matching the rest of my setup. Also restored the explicit focusedBorderColor that had been commented out. Co-Authored-By: Claude Opus 4.7 (1M context) --- xmonad/xmonad.hs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/xmonad/xmonad.hs b/xmonad/xmonad.hs index b4f024a..11d9b06 100644 --- a/xmonad/xmonad.hs +++ b/xmonad/xmonad.hs @@ -29,12 +29,12 @@ main = do spawnPipe "setxkbmap -option 'ctrl:nocaps'" xmonad $ xfceConfig { modMask = mod4Mask -- Rebind Mod to the Windows key - -- , focusedBorderColor = "#000000" + , focusedBorderColor = "#000000" -- , layoutHook = myLayouts -- Change the possible layouts -- , layoutHook = desktopLayoutModifiers $ Mirror (Mirror (Tall 1 0.03 0.5)) -- Change the possible layouts , layoutHook = myLayouts } `additionalKeys` - [ ((mod4Mask .|. shiftMask, xK_Return), spawn "xfce4-terminal") + [ ((mod4Mask .|. shiftMask, xK_Return), spawn "/home/feanil/.local/bin/kitty") , ((mod4Mask , xK_p), spawn "rofi -show run") , ((mod4Mask .|. shiftMask, xK_l), spawn "xfce4-screensaver-command -l") , ((mod4Mask, xK_c), spawn "setxkbmap -option 'ctrl:nocaps'") From 2f06df218e23312571e51b5a6279df56a76ef682 Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Sat, 6 Jun 2026 11:17:43 -0400 Subject: [PATCH 28/67] zsh: add python/uv/kitty plugins, rg alias, tutor + pkg-config env - Enable oh-my-zsh plugins for python, uv, and kitty alongside the existing virtualenvwrapper plugin, and turn on PYTHON_AUTO_VRUN so .venv/venv directories get sourced automatically on cd. - Teach ripgrep about .in files via an --type-add alias. - Set PKG_CONFIG_PATH so locally-built libraries are discoverable. - Point TUTOR_ROOT/TUTOR_PLUGINS_ROOT at my tutor venv install. Co-Authored-By: Claude Opus 4.7 (1M context) --- .zshrc | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.zshrc b/.zshrc index 2ffe157..4c03a23 100644 --- a/.zshrc +++ b/.zshrc @@ -79,6 +79,10 @@ HYPHEN_INSENSITIVE="true" # Would you like to use another custom folder than $ZSH/custom? # ZSH_CUSTOM=/path/to/new-custom-folder +# Automatically find and source virtualenvironments when entering a directory. +PYTHON_AUTO_VRUN=true +PYTHON_VENV_NAMES=".venv venv" + # Which plugins would you like to load? # Standard plugins can be found in ~/.oh-my-zsh/plugins/* # Custom plugins may be added to ~/.oh-my-zsh/custom/plugins/ @@ -90,6 +94,10 @@ plugins=( fzf brew nvm + python + uv + virtualenvwrapper + kitty ) zstyle ':omz:plugins:nvm' autoload yes @@ -121,6 +129,7 @@ export LESS=-FRX alias vim=nvim alias open=xdg-open alias copy=xclip -selection clipboard +alias rg="rg --type-add 'in:*.in'" # Compilation flags # export ARCHFLAGS="-arch x86_64" @@ -147,3 +156,8 @@ export NVM_DIR="$HOME/.nvm" export PATH="/home/feanil/work/src/openedx/edx-platform:$PATH" [[ -f ~/src/dotfiles/config/shellrc/gittree.sh ]] && source ~/src/dotfiles/config/shellrc/gittree.sh + +export PKG_CONFIG_PATH="${PKG_CONFIG_PATH}:/usr/local/lib/x86_64-linux-gnu/pkgconfig:/usr/local/lib/pkgconfig:/usr/local/share/pkgconfig:/usr/lib/x86_64-linux-gnu/pkgconfig:/usr/lib/pkgconfig:/usr/share/pkgconfig" + +TUTOR_ROOT=/home/feanil/src/tutor/.venv/bin/tutor-main +TUTOR_PLUGINS_ROOT=/home/feanil/src/tutor/.venv/bin/tutor-main-plugins From defa437863861aec8e126cd853608d1a91148915 Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Sat, 6 Jun 2026 11:18:11 -0400 Subject: [PATCH 29/67] Add credential-isolated docker sandbox for Claude Code A Debian-bookworm-slim image plus a small set of zsh functions that mount the current directory and Claude's own config read/write while hiding host credentials (~/.aws, ~/.ssh agent/keys, ~/.netrc, ~/.config/gcloud) from the container. UID/GID are baked in at build time so files created in the sandbox are owned correctly on the host, and a read-only GH_TOKEN is pulled from 1Password at startup. Source claude_sandbox/claude-sandbox.sh from .zshrc and run claude-sandbox-build to use it. CLAUDE.md documents the design and what is/isn't available inside. Co-Authored-By: Claude Opus 4.7 (1M context) --- claude_sandbox/CLAUDE.md | 98 +++++++++++++++++++++++++++ claude_sandbox/Dockerfile | 71 +++++++++++++++++++ claude_sandbox/claude-sandbox.sh | 113 +++++++++++++++++++++++++++++++ 3 files changed, 282 insertions(+) create mode 100644 claude_sandbox/CLAUDE.md create mode 100644 claude_sandbox/Dockerfile create mode 100755 claude_sandbox/claude-sandbox.sh diff --git a/claude_sandbox/CLAUDE.md b/claude_sandbox/CLAUDE.md new file mode 100644 index 0000000..340d7b7 --- /dev/null +++ b/claude_sandbox/CLAUDE.md @@ -0,0 +1,98 @@ +# Project: Claude Sandbox + +## Context + +A credential-isolated Docker sandbox for running Claude Code. The sandbox mounts +the current working directory read/write but keeps host credentials (`~/.aws`, +`~/.ssh`, etc.) invisible. Useful when running claude on projects where you don't +want it to have accidental access to cloud credentials or SSH keys. + +## Files + +- `Dockerfile` — Debian bookworm-slim image with claude, node 24, uv + python 3.12, + gh, git, neovim (stable), ripgrep, fd, jq +- `claude-sandbox.sh` — shell functions to source from `.zshrc` + +## Setup + +### Prerequisites + +- Docker running +- 1Password CLI installed and signed in (`op signin`) +- GitHub read-only token at `op://Private/github.com/READ_ONLY_GITHUB_TOKEN` + +### Wire up + +Add to `.zshrc`: +```zsh +source /path/to/claude_sandbox/claude-sandbox.sh +``` + +### Build the image + +```bash +claude-sandbox-build +``` + +Your UID/GID are baked in at build time so files created in the sandbox are owned +correctly on the host. The image auto-rebuilds if it is more than 24 hours old when +you run `claude-sandbox`. + +Override versions for a one-off build: +```bash +PYTHON_VERSION=3.11 NODE_VERSION=22 claude-sandbox-build +``` + +## Commands + +```bash +claude-sandbox # start claude in the current directory +claude-sandbox-shell # open a bash shell in the sandbox +claude-sandbox-shell -c "…" # run a single command and exit +claude-sandbox-build # build or rebuild the image +``` + +## What Is and Isn't Available + +| Resource | Available | Notes | +|---|---|---| +| Current directory | Yes, read/write | Mounted at `/workspace` | +| `~/.claude/` | Yes, read/write | Credentials, history, memory | +| `~/.claude.json` | Yes, read/write | Claude config file (separate from the directory) | +| `~/.gitconfig` | Yes, read-only | Git identity | +| `~/.config/nvim` | Yes, read-only | LazyVim config | +| `~/.local/share/nvim` | Yes, read/write | LazyVim plugins | +| `~/.local/state/nvim` | Yes, read/write | Undo history, shada | +| `~/.local/cache/nvim` | Yes, read/write | TreeSitter parsers | +| `~/.ssh/git-signing-key` | Yes, read-only | Only if present; signing only, not auth | +| `GH_TOKEN` | Yes | Read-only token from 1Password at startup | +| `~/.aws` | **No** | | +| `~/.ssh` (agent/keys) | **No** | | +| `~/.netrc`, `~/.config/gcloud` | **No** | | +| Other host env vars | **No** | | + +## Design Notes + +**`--network host`** — required. Docker's bridge NAT breaks claude's OAuth token +validation. Credential isolation is filesystem-level (mounts), not network-level, +so this doesn't compromise the isolation goal. + +**UID/GID baked at build time** — `claude-sandbox-build` passes `$(id -u)` and +`$(id -g)` as build args so the `sandbox` user inside the container has the same +numeric ID as the host user. Files created in the sandbox are owned correctly on +the host without needing `chown`. + +**`~/.claude.json` is separate from `~/.claude/`** — claude requires both. The +directory holds credentials, history, and memory; the JSON file holds configuration. +Both need to be explicitly mounted. + +**Python** — no system `python` or `python3` symlink. Use `uv venv --python 3.12` +to create project venvs. The python managed by uv is at `UV_PYTHON_INSTALL_DIR=/opt/uv-python`. + +**SSH signing without push** — mount `~/.ssh/git-signing-key` read-only and register +it on GitHub as a *Signing Key* (not Authentication Key). The key can sign commits +locally but cannot authenticate SSH transport, so `git push` over SSH is blocked. + +## Status + +Working as of 2026-06-06. Tested on Linux with Docker 29.5.2. diff --git a/claude_sandbox/Dockerfile b/claude_sandbox/Dockerfile new file mode 100644 index 0000000..e838d4d --- /dev/null +++ b/claude_sandbox/Dockerfile @@ -0,0 +1,71 @@ +ARG PYTHON_VERSION=3.12 +ARG NODE_VERSION=24 +# Baked in at build time to match the host user — see claude-sandbox-build in claude-sandbox.sh +ARG SANDBOX_UID=1000 +ARG SANDBOX_GID=1000 + +FROM debian:bookworm-slim + +ENV DEBIAN_FRONTEND=noninteractive + +ARG PYTHON_VERSION +ARG NODE_VERSION +ARG SANDBOX_UID +ARG SANDBOX_GID + +# Base system tools (including LazyVim deps: ripgrep, fd) +RUN apt-get update && apt-get install -y \ + ca-certificates \ + curl \ + fd-find \ + git \ + gnupg \ + jq \ + ripgrep \ + unzip \ + && ln -sf /usr/bin/fdfind /usr/local/bin/fd \ + && rm -rf /var/lib/apt/lists/* + +# uv — handles both package management and Python version installation +RUN ARCH=$(uname -m) && \ + curl -fsSL "https://github.com/astral-sh/uv/releases/latest/download/uv-${ARCH}-unknown-linux-gnu.tar.gz" \ + | tar -xz -C /tmp && \ + install -m 755 "/tmp/uv-${ARCH}-unknown-linux-gnu/uv" /usr/local/bin/uv && \ + install -m 755 "/tmp/uv-${ARCH}-unknown-linux-gnu/uvx" /usr/local/bin/uvx && \ + rm -rf "/tmp/uv-${ARCH}-unknown-linux-gnu" + +# Python — installed via uv into a shared location (no PPA needed, any version works) +# No system python symlink needed — use `uv venv --python X.Y` in projects. +ENV UV_PYTHON_INSTALL_DIR=/opt/uv-python +RUN uv python install ${PYTHON_VERSION} + +# Node.js via NodeSource (version controlled by NODE_VERSION arg) +RUN curl -fsSL https://deb.nodesource.com/setup_${NODE_VERSION}.x | bash - && \ + apt-get install -y nodejs && \ + rm -rf /var/lib/apt/lists/* + +# GitHub CLI +RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ + | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg && \ + echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \ + | tee /etc/apt/sources.list.d/github-cli.list > /dev/null && \ + apt-get update && apt-get install -y gh && \ + rm -rf /var/lib/apt/lists/* + +# Neovim stable from GitHub releases (apt version is too old for LazyVim) +RUN ARCH=$(uname -m) && \ + [ "$ARCH" = "aarch64" ] && ARCH="arm64" || true && \ + curl -fsSL "https://github.com/neovim/neovim/releases/download/stable/nvim-linux-${ARCH}.tar.gz" \ + | tar -xz -C /opt && \ + ln -sf "/opt/nvim-linux-${ARCH}/bin/nvim" /usr/local/bin/nvim + +# Claude Code (installed globally, accessible to all users) +RUN npm install -g @anthropic-ai/claude-code + +# Create sandbox user with UID/GID matching the host — ensures mounted files are owned correctly +RUN groupadd -g ${SANDBOX_GID} sandbox && \ + useradd -u ${SANDBOX_UID} -g ${SANDBOX_GID} -m -s /bin/bash sandbox + +USER sandbox +WORKDIR /workspace +CMD ["claude"] diff --git a/claude_sandbox/claude-sandbox.sh b/claude_sandbox/claude-sandbox.sh new file mode 100755 index 0000000..fc0b6ec --- /dev/null +++ b/claude_sandbox/claude-sandbox.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +# claude-sandbox.sh — credential-isolated Claude Code sandbox +# +# Prerequisites: +# - Docker running (docker info) +# - 1Password CLI installed and signed in (op signin) +# - GitHub read-only token stored in 1Password at: +# op://Private/github.com/READ_ONLY_GITHUB_TOKEN +# - Image built at least once: claude-sandbox-build +# +# Source from .zshrc: +# source /path/to/dotfiles/claude_sandbox/claude-sandbox.sh +# +# Commands this provides: +# claude-sandbox — run claude in the sandbox from the current directory +# claude-sandbox-shell — open a bash shell in the sandbox for testing +# claude-sandbox-build — build (or rebuild) the sandbox image + +CLAUDE_SANDBOX_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" +CLAUDE_SANDBOX_IMAGE="claude-sandbox:latest" + +# Build or rebuild the sandbox image. +# Your UID/GID are baked in at build time so file ownership matches the host. +# +# Override versions for a one-off build: +# PYTHON_VERSION=3.11 NODE_VERSION=22 claude-sandbox-build +claude-sandbox-build() { + docker build \ + --build-arg PYTHON_VERSION="${PYTHON_VERSION:-3.12}" \ + --build-arg NODE_VERSION="${NODE_VERSION:-24}" \ + --build-arg SANDBOX_UID="$(id -u)" \ + --build-arg SANDBOX_GID="$(id -g)" \ + -f "$CLAUDE_SANDBOX_DIR/Dockerfile" \ + -t "$CLAUDE_SANDBOX_IMAGE" \ + "$CLAUDE_SANDBOX_DIR" +} + +# Run claude in a credential-isolated sandbox with the current directory mounted. +# +# What is NOT available inside the sandbox: +# ~/.aws, ~/.ssh (agent + keys), host env vars, ~/.netrc, ~/.config/gcloud, etc. +# +# What IS available: +# /workspace — current directory, read/write +# ~/.claude — claude login, config, and history (read/write) +# ~/.claude.json — claude configuration file (read/write) +# ~/.gitconfig — read-only +# ~/.config/nvim — read-only (LazyVim config) +# ~/.local/share/nvim — read/write (LazyVim plugins, persisted on host) +# ~/.local/state/nvim — read/write (undo history, shada) +# ~/.local/cache/nvim — read/write (TreeSitter parsers, etc.) +# ~/.ssh/git-signing-key — read-only, if present (for commit signing only) +# +# Any extra arguments are forwarded to claude: +# claude-sandbox "fix the tests" +# Shared setup and docker run — takes the container command as arguments. +_claude_sandbox_run() { + local -a extra_flags=() + + if [[ -f "$HOME/.ssh/git-signing-key" ]]; then + extra_flags+=(-v "$HOME/.ssh/git-signing-key:/home/sandbox/.ssh/git-signing-key:ro") + fi + + if ! docker image inspect "$CLAUDE_SANDBOX_IMAGE" &>/dev/null; then + echo "claude-sandbox: error: image '$CLAUDE_SANDBOX_IMAGE' not found — run claude-sandbox-build first" >&2 + return 1 + fi + + local created created_epoch now_epoch + created=$(docker image inspect "$CLAUDE_SANDBOX_IMAGE" --format '{{.Created}}') + created_epoch=$(date -d "$created" +%s) + now_epoch=$(date +%s) + if (( now_epoch - created_epoch > 86400 )); then + echo "claude-sandbox: image is more than 24h old, rebuilding..." >&2 + claude-sandbox-build || return 1 + fi + + local gh_token + if ! gh_token=$(op read "op://Private/github.com/READ_ONLY_GITHUB_TOKEN" 2>/dev/null); then + echo "claude-sandbox: error: could not read GitHub token from 1Password (is op signed in?)" >&2 + return 1 + fi + extra_flags+=(-e "GH_TOKEN=$gh_token") + + # --network host: Docker's bridge NAT breaks claude's OAuth token validation + docker run -it --rm \ + --network host \ + -e HOME=/home/sandbox \ + -v "$PWD:/workspace" \ + -w /workspace \ + -v "$HOME/.claude:/home/sandbox/.claude" \ + -v "$HOME/.claude.json:/home/sandbox/.claude.json" \ + -v "$HOME/.gitconfig:/home/sandbox/.gitconfig:ro" \ + -v "$HOME/.config/nvim:/home/sandbox/.config/nvim:ro" \ + -v "$HOME/.local/share/nvim:/home/sandbox/.local/share/nvim" \ + -v "$HOME/.local/state/nvim:/home/sandbox/.local/state/nvim" \ + -v "$HOME/.local/cache/nvim:/home/sandbox/.local/cache/nvim" \ + "${extra_flags[@]}" \ + "$CLAUDE_SANDBOX_IMAGE" \ + "$@" +} + +# Run claude in the sandbox from the current directory. +# Any extra arguments are forwarded to claude: claude-sandbox "fix the tests" +claude-sandbox() { + _claude_sandbox_run claude "$@" +} + +# Open a bash shell in the sandbox from the current directory. +# Extra arguments are passed to bash: claude-sandbox-shell -c "echo $HOME" +claude-sandbox-shell() { + _claude_sandbox_run bash "$@" +} From 415e8923c0daec769aacbe32820862f277279e0b Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Sat, 6 Jun 2026 11:32:42 -0400 Subject: [PATCH 30/67] git: move signingkey into gitconfig.core with tilde-expanded path Both personal and work used the same signing key but referenced slightly different absolute paths, and a hardcoded /home/feanil/... won't resolve inside the claude-sandbox container where HOME is /home/sandbox. Hoist user.signingkey into gitconfig.core as ~/.ssh/signing_key.pub so the lookup works in any HOME, and drop the redundant entries from the per-context configs. Co-Authored-By: Claude Opus 4.7 (1M context) --- gitconfig.core | 3 +++ gitconfig.personal | 1 - gitconfig.work | 1 - 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/gitconfig.core b/gitconfig.core index c75056b..0d36386 100644 --- a/gitconfig.core +++ b/gitconfig.core @@ -4,6 +4,9 @@ [includeIf "gitdir:~/src/feanil/"] path = src/feanil/dotfiles/gitconfig.personal +[user] + signingkey = ~/.ssh/signing_key.pub + [init] defaultBranch = main diff --git a/gitconfig.personal b/gitconfig.personal index 5abfc61..65b14f5 100644 --- a/gitconfig.personal +++ b/gitconfig.personal @@ -1,4 +1,3 @@ [user] email = github@feanil.com name = Feanil Patel - signingkey = /home/feanil/.ssh/signing_key.pub diff --git a/gitconfig.work b/gitconfig.work index bb3ba5e..ebd28bd 100644 --- a/gitconfig.work +++ b/gitconfig.work @@ -1,4 +1,3 @@ [user] email = feanil@axim.org name = Feanil Patel - signingkey = /home/feanil/.ssh/signing_public_key From abaa1403f1bb1afaa2c846b97d43ee0400bee6ed Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Sat, 6 Jun 2026 11:33:02 -0400 Subject: [PATCH 31/67] claude_sandbox: align signing key path with user.signingkey The sandbox previously mounted ~/.ssh/git-signing-key, an ad-hoc path that didn't match anything in the host gitconfig. Switch to ~/.ssh/signing_key{,.pub} at the same path inside the container so the user.signingkey entry in gitconfig.core (~/.ssh/signing_key.pub) resolves correctly inside the sandbox too. Both private and public files are mounted because git ssh-signing needs both. Co-Authored-By: Claude Opus 4.7 (1M context) --- claude_sandbox/CLAUDE.md | 10 ++++++---- claude_sandbox/claude-sandbox.sh | 9 ++++++--- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/claude_sandbox/CLAUDE.md b/claude_sandbox/CLAUDE.md index 340d7b7..31ddeb3 100644 --- a/claude_sandbox/CLAUDE.md +++ b/claude_sandbox/CLAUDE.md @@ -64,7 +64,7 @@ claude-sandbox-build # build or rebuild the image | `~/.local/share/nvim` | Yes, read/write | LazyVim plugins | | `~/.local/state/nvim` | Yes, read/write | Undo history, shada | | `~/.local/cache/nvim` | Yes, read/write | TreeSitter parsers | -| `~/.ssh/git-signing-key` | Yes, read-only | Only if present; signing only, not auth | +| `~/.ssh/signing_key{,.pub}` | Yes, read-only | Only if present; matches `user.signingkey` in `gitconfig.core`. Signing only, not auth. | | `GH_TOKEN` | Yes | Read-only token from 1Password at startup | | `~/.aws` | **No** | | | `~/.ssh` (agent/keys) | **No** | | @@ -89,9 +89,11 @@ Both need to be explicitly mounted. **Python** — no system `python` or `python3` symlink. Use `uv venv --python 3.12` to create project venvs. The python managed by uv is at `UV_PYTHON_INSTALL_DIR=/opt/uv-python`. -**SSH signing without push** — mount `~/.ssh/git-signing-key` read-only and register -it on GitHub as a *Signing Key* (not Authentication Key). The key can sign commits -locally but cannot authenticate SSH transport, so `git push` over SSH is blocked. +**SSH signing without push** — mount `~/.ssh/signing_key` (and `.pub`) read-only at +the same path inside the container so the `user.signingkey = ~/.ssh/signing_key.pub` +entry in `gitconfig.core` resolves correctly on both sides. Register the key on +GitHub as a *Signing Key* (not Authentication Key) so it can sign commits locally +but cannot authenticate SSH transport — `git push` over SSH stays blocked. ## Status diff --git a/claude_sandbox/claude-sandbox.sh b/claude_sandbox/claude-sandbox.sh index fc0b6ec..d1d0d4c 100755 --- a/claude_sandbox/claude-sandbox.sh +++ b/claude_sandbox/claude-sandbox.sh @@ -49,7 +49,7 @@ claude-sandbox-build() { # ~/.local/share/nvim — read/write (LazyVim plugins, persisted on host) # ~/.local/state/nvim — read/write (undo history, shada) # ~/.local/cache/nvim — read/write (TreeSitter parsers, etc.) -# ~/.ssh/git-signing-key — read-only, if present (for commit signing only) +# ~/.ssh/signing_key{,.pub} — read-only, if present (for commit signing only) # # Any extra arguments are forwarded to claude: # claude-sandbox "fix the tests" @@ -57,8 +57,11 @@ claude-sandbox-build() { _claude_sandbox_run() { local -a extra_flags=() - if [[ -f "$HOME/.ssh/git-signing-key" ]]; then - extra_flags+=(-v "$HOME/.ssh/git-signing-key:/home/sandbox/.ssh/git-signing-key:ro") + if [[ -f "$HOME/.ssh/signing_key" ]]; then + extra_flags+=( + -v "$HOME/.ssh/signing_key:/home/sandbox/.ssh/signing_key:ro" + -v "$HOME/.ssh/signing_key.pub:/home/sandbox/.ssh/signing_key.pub:ro" + ) fi if ! docker image inspect "$CLAUDE_SANDBOX_IMAGE" &>/dev/null; then From 6fb8a863bf4b015b7cfdd81e10999847fa807849 Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Mon, 8 Jun 2026 09:53:51 -0400 Subject: [PATCH 32/67] Move claude_sandbox under config/shellrc and source from .zshrc Co-locate the claude-sandbox wrapper, Dockerfile, and notes with the existing gittree.sh shell snippet so all sourced shell helpers live under config/shellrc/. The .zshrc now sources the wrapper from its new location (guarded by [[ -f ]] like the gittree source) so the claude-sandbox / claude-sandbox-shell / claude-sandbox-build commands are available in interactive shells without extra setup. The in-script CLAUDE_SANDBOX_DIR still resolves to the script's own dir, so the Dockerfile lookup keeps working unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- .zshrc | 2 ++ {claude_sandbox => config/shellrc/claude_sandbox}/CLAUDE.md | 2 +- {claude_sandbox => config/shellrc/claude_sandbox}/Dockerfile | 0 .../shellrc/claude_sandbox}/claude-sandbox.sh | 2 +- 4 files changed, 4 insertions(+), 2 deletions(-) rename {claude_sandbox => config/shellrc/claude_sandbox}/CLAUDE.md (98%) rename {claude_sandbox => config/shellrc/claude_sandbox}/Dockerfile (100%) rename {claude_sandbox => config/shellrc/claude_sandbox}/claude-sandbox.sh (98%) diff --git a/.zshrc b/.zshrc index 4c03a23..dfb21bf 100644 --- a/.zshrc +++ b/.zshrc @@ -157,6 +157,8 @@ export PATH="/home/feanil/work/src/openedx/edx-platform:$PATH" [[ -f ~/src/dotfiles/config/shellrc/gittree.sh ]] && source ~/src/dotfiles/config/shellrc/gittree.sh +[[ -f ~/src/feanil/dotfiles/config/shellrc/claude_sandbox/claude-sandbox.sh ]] && source ~/src/feanil/dotfiles/config/shellrc/claude_sandbox/claude-sandbox.sh + export PKG_CONFIG_PATH="${PKG_CONFIG_PATH}:/usr/local/lib/x86_64-linux-gnu/pkgconfig:/usr/local/lib/pkgconfig:/usr/local/share/pkgconfig:/usr/lib/x86_64-linux-gnu/pkgconfig:/usr/lib/pkgconfig:/usr/share/pkgconfig" TUTOR_ROOT=/home/feanil/src/tutor/.venv/bin/tutor-main diff --git a/claude_sandbox/CLAUDE.md b/config/shellrc/claude_sandbox/CLAUDE.md similarity index 98% rename from claude_sandbox/CLAUDE.md rename to config/shellrc/claude_sandbox/CLAUDE.md index 31ddeb3..8792cee 100644 --- a/claude_sandbox/CLAUDE.md +++ b/config/shellrc/claude_sandbox/CLAUDE.md @@ -25,7 +25,7 @@ want it to have accidental access to cloud credentials or SSH keys. Add to `.zshrc`: ```zsh -source /path/to/claude_sandbox/claude-sandbox.sh +source /path/to/dotfiles/config/shellrc/claude_sandbox/claude-sandbox.sh ``` ### Build the image diff --git a/claude_sandbox/Dockerfile b/config/shellrc/claude_sandbox/Dockerfile similarity index 100% rename from claude_sandbox/Dockerfile rename to config/shellrc/claude_sandbox/Dockerfile diff --git a/claude_sandbox/claude-sandbox.sh b/config/shellrc/claude_sandbox/claude-sandbox.sh similarity index 98% rename from claude_sandbox/claude-sandbox.sh rename to config/shellrc/claude_sandbox/claude-sandbox.sh index d1d0d4c..62deaf5 100755 --- a/claude_sandbox/claude-sandbox.sh +++ b/config/shellrc/claude_sandbox/claude-sandbox.sh @@ -9,7 +9,7 @@ # - Image built at least once: claude-sandbox-build # # Source from .zshrc: -# source /path/to/dotfiles/claude_sandbox/claude-sandbox.sh +# source /path/to/dotfiles/config/shellrc/claude_sandbox/claude-sandbox.sh # # Commands this provides: # claude-sandbox — run claude in the sandbox from the current directory From 1e560d4b8c02980d46d02604881d8c8c46db6ab1 Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Mon, 8 Jun 2026 09:54:36 -0400 Subject: [PATCH 33/67] Drop unused xmobar config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No longer running xmobar — xmonad uses xfce4-panel via xfceConfig and no part of the repo references xmobarrc, so the file was just dead weight. Co-Authored-By: Claude Opus 4.7 (1M context) --- config/xmobar/xmobarrc | 23 ----------------------- 1 file changed, 23 deletions(-) delete mode 100644 config/xmobar/xmobarrc diff --git a/config/xmobar/xmobarrc b/config/xmobar/xmobarrc deleted file mode 100644 index 3ad4109..0000000 --- a/config/xmobar/xmobarrc +++ /dev/null @@ -1,23 +0,0 @@ -Config { font = "-*-Fixed-Bold-R-Normal-*-13-*-*-*-*-*-*-*" - , bgColor = "black" - , fgColor = "grey" - , position = TopW L 90 - , commands = [ Run Cpu ["-L","3","-H","50","--normal","green","--high","red"] 10 - , Run Memory ["-t","Mem: %"] 10 - , Run Swap [] 10 - , Run Date "%a %b %_d %l:%M" "date" 10 - , Run BatteryP ["BAT0"] - ["-t", " (%)", - "-L", "10", "-H", "80", "-p", "3", - "--", "-O", "Charging - ", "-i", "", - "-o", "Battery", - "-L", "-15", "-H", "-5", - "-l", "red", "-m", "blue", "-h", "green"] 600 - , Run Wireless "wlp4s0" [] 600 - , Run StdinReader - ] - , sepChar = "%" - , alignSep = "}{" - , template = "%StdinReader% }{ %wlp4s0wi% | %battery% | %cpu% | %memory% | %swap% %date%" - , overrideRedirect = False - } From c54830b9d4bc08587b7322036eb64a6b07b52894 Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Mon, 8 Jun 2026 10:31:14 -0400 Subject: [PATCH 34/67] fix: forward COLORTERM and hardcode TERM=xterm-256color in the sandbox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude was warning "Try setting environment variable COLORTERM=truecolor for richer colors" because docker run starts with an empty env outside of what we explicitly pass. Forward COLORTERM (defaulting to truecolor) so truecolor capability is advertised correctly inside the container. TERM is intentionally not forwarded — Debian's base ncurses ships terminfo for xterm-256color but not tmux-256color, so blindly passing through the host TERM from a tmux session would actually degrade ncurses programs in the container. The docker pty's capabilities are accurately described by xterm-256color regardless of what is forwarding the bytes outside. Co-Authored-By: Claude Opus 4.7 (1M context) --- config/shellrc/claude_sandbox/claude-sandbox.sh | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/config/shellrc/claude_sandbox/claude-sandbox.sh b/config/shellrc/claude_sandbox/claude-sandbox.sh index 62deaf5..5dc90c7 100755 --- a/config/shellrc/claude_sandbox/claude-sandbox.sh +++ b/config/shellrc/claude_sandbox/claude-sandbox.sh @@ -85,10 +85,17 @@ _claude_sandbox_run() { fi extra_flags+=(-e "GH_TOKEN=$gh_token") - # --network host: Docker's bridge NAT breaks claude's OAuth token validation docker run -it --rm \ + `# Docker's bridge NAT breaks claude's OAuth token validation.` \ --network host \ -e HOME=/home/sandbox \ + `# TERM is hardcoded rather than forwarded: Debian's base ncurses only` \ + `# ships terminfo for a small set of TERM values (xterm-256color is in,` \ + `# tmux-256color is not), so forwarding TERM=tmux-256color from a host` \ + `# shell inside tmux would degrade ncurses programs in the container.` \ + `# Truecolor is advertised separately via COLORTERM.` \ + -e TERM=xterm-256color \ + -e "COLORTERM=${COLORTERM:-truecolor}" \ -v "$PWD:/workspace" \ -w /workspace \ -v "$HOME/.claude:/home/sandbox/.claude" \ From d78dbf5d9ac6dc72b720c971e6764b7e9d58d2a3 Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Tue, 9 Jun 2026 15:27:41 -0400 Subject: [PATCH 35/67] claude_sandbox: always build images fresh Add --no-cache and --pull to claude-sandbox-build so each rebuild fetches the latest base image and reinstalls tools. The image already auto-rebuilds when it is more than 24h old, so paying the cache miss is worth getting current security/feature updates. Co-Authored-By: Claude Opus 4.7 (1M context) --- config/shellrc/claude_sandbox/claude-sandbox.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/config/shellrc/claude_sandbox/claude-sandbox.sh b/config/shellrc/claude_sandbox/claude-sandbox.sh index 5dc90c7..b8b34d7 100755 --- a/config/shellrc/claude_sandbox/claude-sandbox.sh +++ b/config/shellrc/claude_sandbox/claude-sandbox.sh @@ -26,6 +26,8 @@ CLAUDE_SANDBOX_IMAGE="claude-sandbox:latest" # PYTHON_VERSION=3.11 NODE_VERSION=22 claude-sandbox-build claude-sandbox-build() { docker build \ + --no-cache \ + --pull \ --build-arg PYTHON_VERSION="${PYTHON_VERSION:-3.12}" \ --build-arg NODE_VERSION="${NODE_VERSION:-24}" \ --build-arg SANDBOX_UID="$(id -u)" \ From fea37303b095966c55a5a25b25c2f61f21e2ea48 Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Tue, 9 Jun 2026 15:27:56 -0400 Subject: [PATCH 36/67] claude_sandbox: pass git identity via env vars The container's HOME is /home/sandbox, but the host gitconfig uses includeIf paths relative to the host's HOME (e.g. src/feanil/dotfiles/ gitconfig.work). Those includes never resolve inside the container, so user.name / user.email come back empty and commits fail. Resolve the identity on the host where includeIf works, then forward it in via GIT_AUTHOR_* / GIT_COMMITTER_* environment variables. Co-Authored-By: Claude Opus 4.7 (1M context) --- config/shellrc/claude_sandbox/claude-sandbox.sh | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/config/shellrc/claude_sandbox/claude-sandbox.sh b/config/shellrc/claude_sandbox/claude-sandbox.sh index b8b34d7..87a32cd 100755 --- a/config/shellrc/claude_sandbox/claude-sandbox.sh +++ b/config/shellrc/claude_sandbox/claude-sandbox.sh @@ -87,6 +87,15 @@ _claude_sandbox_run() { fi extra_flags+=(-e "GH_TOKEN=$gh_token") + # Resolve git identity on the host (where includeIf works) and pass the + # values in directly — the container can't follow the relative include paths + # because its $HOME differs from the host's. + local git_name git_email + git_name=$(git config user.name 2>/dev/null) + git_email=$(git config user.email 2>/dev/null) + [[ -n "$git_name" ]] && extra_flags+=(-e "GIT_AUTHOR_NAME=$git_name" -e "GIT_COMMITTER_NAME=$git_name") + [[ -n "$git_email" ]] && extra_flags+=(-e "GIT_AUTHOR_EMAIL=$git_email" -e "GIT_COMMITTER_EMAIL=$git_email") + docker run -it --rm \ `# Docker's bridge NAT breaks claude's OAuth token validation.` \ --network host \ From 230f521044473c2d929e9a5bb3156c6123b9fe37 Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Tue, 9 Jun 2026 15:28:09 -0400 Subject: [PATCH 37/67] Add ed25519 signing key to allowed signers So git can verify signatures made with the new ~/.ssh/signing_key. Co-Authored-By: Claude Opus 4.7 (1M context) --- git_allowed_signers | 1 + 1 file changed, 1 insertion(+) diff --git a/git_allowed_signers b/git_allowed_signers index c642947..0a84cad 100644 --- a/git_allowed_signers +++ b/git_allowed_signers @@ -2,3 +2,4 @@ feanil@axim.org ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDH1ZvaTRhSE6eS7RNwUwFcTUbk feanil@gmail.com ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDH1ZvaTRhSE6eS7RNwUwFcTUbkK4/WFgO3TaW1S4Gr+9FKkJ8rzBusSCYPbwS6XgEJGnLB2qBQlnK3EvSmljKgVLBeruxiJInz1Gx7184NOvKDmE4+gFe46MThNl7FwA02NwYk0tnLHu9zYL3DjS1um6Txy/K0GEyqliGGq8tcuBwWb8xKlE7/aTEu1zaqH592q4JB7zDUV/iNuxUKU70EAu2gzHmmC7AhewYnVoVKou6YBvlvB5pfVgHgdcHF7EBV2mIidIwQzDDzDyLRg5zxTZ/ABfjhd/2En6o2NYEftOWqSbzfmEInBavvIeqyJExPvUVh7EWs635dZ6JB9NVhNJY0u58xTAItWL3ZNFlmv1ENvY2UKsU5b2NnLFopezuNreouIb6jE6zHqrT1z2jYfMYO3/hAMY3/NyCyvPrJiQd/obP7yDswq1fgXEKt3VJoMgMcEIBt/Yl7RhGwnawAO3HujFk+cAOmFA10zd4j3svRZ9ZMO8tjD5BSkonxE89YvLb5IPCLR4V1l85g3DVF8tHxp+SJWTX2ma8LGGg1/bjvkgAZ2P8w/qEF8Iw0ONgYUuaJvfDsHv9KjoBWXzbhjcnlfA2UFRTuNuGFRKkip6+BKhGcFBE1U5z1H6NufOc2f6rNwGoN/+0+kCcf/YnvCBx4j4c13k3DHelaH6FG2Q== feanil@gmail.com feanil@axim.org ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAxWEVmi5CYIs2zrwPPC9VrVigKz9e37p8WJsSkpGFQp feanil@axim.org inbox@feanil.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINhnaZF9qPYwJLSbI+IBlv70s9RfVeXcL/giKPzxx69E +feanil@axim.org ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJXAQ0Pa5Yk9pMfFhaDluL0pRGhqCZQe3mwpMGiDfCG6 signing_key From 8ab1a58974930f39d47a2add55113be7592c11c3 Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Wed, 10 Jun 2026 13:33:03 +0000 Subject: [PATCH 38/67] claude_sandbox: explain the 1Password prompt before reading the token op read can trigger a 1Password authorization prompt (biometric/system auth) with no context. Print what is being read and why beforehand so the prompt isn't a mystery. Co-Authored-By: Claude Opus 4.8 (1M context) --- config/shellrc/claude_sandbox/claude-sandbox.sh | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/config/shellrc/claude_sandbox/claude-sandbox.sh b/config/shellrc/claude_sandbox/claude-sandbox.sh index 87a32cd..4e5e117 100755 --- a/config/shellrc/claude_sandbox/claude-sandbox.sh +++ b/config/shellrc/claude_sandbox/claude-sandbox.sh @@ -80,6 +80,12 @@ _claude_sandbox_run() { claude-sandbox-build || return 1 fi + # op read may trigger a 1Password authorization prompt (biometric/system + # auth). Explain why before it appears so it isn't a mystery prompt. + echo "claude-sandbox: reading the read-only GitHub token from 1Password" >&2 + echo " (op://Private/github.com/READ_ONLY_GITHUB_TOKEN) to pass into the sandbox as GH_TOKEN." >&2 + echo " 1Password may prompt you to authorize access." >&2 + local gh_token if ! gh_token=$(op read "op://Private/github.com/READ_ONLY_GITHUB_TOKEN" 2>/dev/null); then echo "claude-sandbox: error: could not read GitHub token from 1Password (is op signed in?)" >&2 From 0efc6684c4bf35aa0e2dcebe52d48eb66fb1ed7a Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Wed, 10 Jun 2026 13:33:35 +0000 Subject: [PATCH 39/67] claude_sandbox: add a SANDBOX statusline badge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Running claude inside claude makes it easy to lose track of which one you're in. Add a statusline that renders a prominent "🧪 SANDBOX" badge plus the model and cwd. The statusline script is baked into the image as claude-sandbox-statusline and wired up at launch with `claude --settings`, passed as an inline JSON string. Because ~/.claude/settings.json is mounted read/write from the host, configuring the statusline there would leak the badge onto the host claude; layering it on at launch keeps it sandbox-only and writes no second settings file. A CLAUDE_SANDBOX=1 env var marks the environment for anything else that wants to detect the sandbox. Co-Authored-By: Claude Opus 4.8 (1M context) --- config/shellrc/claude_sandbox/Dockerfile | 4 ++++ config/shellrc/claude_sandbox/claude-sandbox.sh | 13 ++++++++++++- config/shellrc/claude_sandbox/statusline.sh | 15 +++++++++++++++ 3 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 config/shellrc/claude_sandbox/statusline.sh diff --git a/config/shellrc/claude_sandbox/Dockerfile b/config/shellrc/claude_sandbox/Dockerfile index e838d4d..9ddc579 100644 --- a/config/shellrc/claude_sandbox/Dockerfile +++ b/config/shellrc/claude_sandbox/Dockerfile @@ -62,6 +62,10 @@ RUN ARCH=$(uname -m) && \ # Claude Code (installed globally, accessible to all users) RUN npm install -g @anthropic-ai/claude-code +# Sandbox statusline badge — wired up at launch via --settings in claude-sandbox.sh +COPY statusline.sh /usr/local/bin/claude-sandbox-statusline +RUN chmod +x /usr/local/bin/claude-sandbox-statusline + # Create sandbox user with UID/GID matching the host — ensures mounted files are owned correctly RUN groupadd -g ${SANDBOX_GID} sandbox && \ useradd -u ${SANDBOX_UID} -g ${SANDBOX_GID} -m -s /bin/bash sandbox diff --git a/config/shellrc/claude_sandbox/claude-sandbox.sh b/config/shellrc/claude_sandbox/claude-sandbox.sh index 4e5e117..e4d81ef 100755 --- a/config/shellrc/claude_sandbox/claude-sandbox.sh +++ b/config/shellrc/claude_sandbox/claude-sandbox.sh @@ -106,6 +106,8 @@ _claude_sandbox_run() { `# Docker's bridge NAT breaks claude's OAuth token validation.` \ --network host \ -e HOME=/home/sandbox \ + `# Marks the environment as the sandbox — drives the statusline badge.` \ + -e CLAUDE_SANDBOX=1 \ `# TERM is hardcoded rather than forwarded: Debian's base ncurses only` \ `# ships terminfo for a small set of TERM values (xterm-256color is in,` \ `# tmux-256color is not), so forwarding TERM=tmux-256color from a host` \ @@ -129,8 +131,17 @@ _claude_sandbox_run() { # Run claude in the sandbox from the current directory. # Any extra arguments are forwarded to claude: claude-sandbox "fix the tests" +# +# A statusline badge is layered on at launch via --settings (sandbox-only, +# nothing persisted) so *you* can tell at a glance you're in the sandbox. claude-sandbox() { - _claude_sandbox_run claude "$@" + # Statusline merged on top of ~/.claude/settings.json for this launch only — + # passed as an inline JSON string, so no second settings file is written. + local statusline_settings='{"statusLine":{"type":"command","command":"claude-sandbox-statusline"}}' + + _claude_sandbox_run claude \ + --settings "$statusline_settings" \ + "$@" } # Open a bash shell in the sandbox from the current directory. diff --git a/config/shellrc/claude_sandbox/statusline.sh b/config/shellrc/claude_sandbox/statusline.sh new file mode 100644 index 0000000..edf2b0d --- /dev/null +++ b/config/shellrc/claude_sandbox/statusline.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# claude-sandbox-statusline — statusline for the credential-isolated sandbox. +# +# Wired up at launch via --settings in claude-sandbox.sh (NOT in the shared +# ~/.claude/settings.json), so the badge only ever shows inside the sandbox. +# +# Claude Code feeds this command the status JSON on stdin; we print a one-line +# status led by a prominent SANDBOX badge so it's obvious this is the nested +# sandbox Claude rather than the host Claude. +input=$(cat) +model=$(printf '%s' "$input" | jq -r '.model.display_name // "claude"') +dir=$(printf '%s' "$input" | jq -r '.workspace.current_dir // .cwd // ""') + +# Black text on a yellow background for the badge, then bold model + dim path. +printf '\033[30;43m 🧪 SANDBOX \033[0m \033[1m%s\033[0m \033[2m%s\033[0m' "$model" "$dir" From d4f3184835c6b73feae97813c37d5256845ec70e Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Wed, 10 Jun 2026 13:33:51 +0000 Subject: [PATCH 40/67] claude_sandbox: tell claude it's sandboxed via the system prompt So claude itself can tell it's in the sandbox, pass a short note via --append-system-prompt describing what is and isn't available (read-only GH_TOKEN, no SSH auth/agent, no ~/.aws/gcloud/netrc, no host env). This keeps claude from wasting effort on credentials that aren't there. Injected at launch rather than written to the host-shared ~/.claude/CLAUDE.md, so it stays sandbox-only. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../shellrc/claude_sandbox/claude-sandbox.sh | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/config/shellrc/claude_sandbox/claude-sandbox.sh b/config/shellrc/claude_sandbox/claude-sandbox.sh index e4d81ef..226356d 100755 --- a/config/shellrc/claude_sandbox/claude-sandbox.sh +++ b/config/shellrc/claude_sandbox/claude-sandbox.sh @@ -132,15 +132,35 @@ _claude_sandbox_run() { # Run claude in the sandbox from the current directory. # Any extra arguments are forwarded to claude: claude-sandbox "fix the tests" # -# A statusline badge is layered on at launch via --settings (sandbox-only, -# nothing persisted) so *you* can tell at a glance you're in the sandbox. +# Two sandbox cues are layered on at launch (sandbox-only, nothing persisted): +# --settings adds a statusline badge so *you* can tell at a glance. +# --append-system-prompt tells *claude* it's sandboxed and what is / isn't there. claude-sandbox() { # Statusline merged on top of ~/.claude/settings.json for this launch only — # passed as an inline JSON string, so no second settings file is written. local statusline_settings='{"statusLine":{"type":"command","command":"claude-sandbox-statusline"}}' + local sandbox_note + sandbox_note=$(cat <<'EOF' +You are running inside "claude-sandbox", a credential-isolated Docker container, +not on the host machine. + +Available: the current directory at /workspace (read/write); your ~/.claude config +and history; ~/.gitconfig and git identity; an SSH signing key (commit signing only, +if present); and GH_TOKEN, a READ-ONLY GitHub token. + +Not available (do not try to use these): ~/.aws, SSH auth keys and the SSH agent, +~/.config/gcloud, ~/.netrc, and host environment variables. Because GH_TOKEN is +read-only and SSH auth is blocked, gh/git operations that write to GitHub (push, +PR edits, etc.) will fail. + +Mention this sandbox context when it's relevant to what the user asks. +EOF +) + _claude_sandbox_run claude \ --settings "$statusline_settings" \ + --append-system-prompt "$sandbox_note" \ "$@" } From 132dcf59526c42fb1dab4ec3a10bd33b8342a312 Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Wed, 10 Jun 2026 13:33:56 +0000 Subject: [PATCH 41/67] claude_sandbox: document the sandbox indicators and op prompt Co-Authored-By: Claude Opus 4.8 (1M context) --- config/shellrc/claude_sandbox/CLAUDE.md | 29 +++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/config/shellrc/claude_sandbox/CLAUDE.md b/config/shellrc/claude_sandbox/CLAUDE.md index 8792cee..d72c91c 100644 --- a/config/shellrc/claude_sandbox/CLAUDE.md +++ b/config/shellrc/claude_sandbox/CLAUDE.md @@ -12,6 +12,8 @@ want it to have accidental access to cloud credentials or SSH keys. - `Dockerfile` — Debian bookworm-slim image with claude, node 24, uv + python 3.12, gh, git, neovim (stable), ripgrep, fd, jq - `claude-sandbox.sh` — shell functions to source from `.zshrc` +- `statusline.sh` — baked into the image as `claude-sandbox-statusline`; renders the + `🧪 SANDBOX` badge. Wired up at launch via `--settings`, never in `settings.json`. ## Setup @@ -71,6 +73,33 @@ claude-sandbox-build # build or rebuild the image | `~/.netrc`, `~/.config/gcloud` | **No** | | | Other host env vars | **No** | | +## Telling You're in the Sandbox + +The sandbox runs Claude *inside* Claude, so it's easy to lose track of which one +you're in. Three cues make it obvious, all driven off the `CLAUDE_SANDBOX=1` env +var set on the container: + +- **Statusline badge** — a `🧪 SANDBOX` badge (black on yellow) plus model and + cwd. Rendered by `claude-sandbox-statusline` (baked into the image) and wired up + at launch with `claude --settings '{"statusLine":{...}}'`. Passing it as an inline + JSON string means it merges on top of `~/.claude/settings.json` for that launch + only — **no second settings file is written**, and the host Claude never shows it. +- **System-prompt note** — `claude-sandbox` passes `--append-system-prompt` with a + short note telling Claude it's sandboxed and what is / isn't available, so Claude + itself can tell. Sandbox-only; nothing is written to the shared `~/.claude/CLAUDE.md`. +- **`CLAUDE_SANDBOX=1`** — available to any script or agent that wants to detect the + sandbox. + +Both `~/.claude/settings.json` and `~/.claude/CLAUDE.md` are mounted read/write from +the host, so neither cue is configured there — that would leak onto the host Claude. +Both are layered on at launch via CLI flags instead. + +## 1Password prompt + +Before reading the GitHub token, `claude-sandbox` prints why it's about to call +`op read`, so the 1Password authorization prompt (biometric/system auth) isn't a +mystery. + ## Design Notes **`--network host`** — required. Docker's bridge NAT breaks claude's OAuth token From 2c3c5476244692b59b4735601145b005b62112a9 Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Wed, 10 Jun 2026 13:56:56 +0000 Subject: [PATCH 42/67] claude_sandbox: queue privileged actions for a monitored handoff The sandbox can do the work but lacks credentials to push, open PRs, deploy, etc. Rather than have claude improvise around the wall or lose the intent, extend the injected system-prompt note with a handoff protocol: - Artifact handoff: finish all local work and commit to a branch (or write a patch) so the privileged step only transmits it, never re-derives it. - Structured queue: append the residual privileged steps as concrete, paste-ready commands to ./PRIVILEGED.md, in a status-tracked format a separate monitored privileged session reviews and runs. Driven entirely by the system prompt (no enforcement hook), so it stays advisory and sandbox-only. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../shellrc/claude_sandbox/claude-sandbox.sh | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/config/shellrc/claude_sandbox/claude-sandbox.sh b/config/shellrc/claude_sandbox/claude-sandbox.sh index 226356d..0f00fad 100755 --- a/config/shellrc/claude_sandbox/claude-sandbox.sh +++ b/config/shellrc/claude_sandbox/claude-sandbox.sh @@ -154,6 +154,31 @@ Not available (do not try to use these): ~/.aws, SSH auth keys and the SSH agent read-only and SSH auth is blocked, gh/git operations that write to GitHub (push, PR edits, etc.) will fail. +When a task needs an action you cannot perform here (anything requiring those +missing credentials — git push, gh PR create/merge/edit, AWS, gcloud, terraform +apply, deploys, sudo), do NOT attempt it and do NOT work around it. Instead: + 1. Finish everything you CAN do locally first, and leave a ready-to-ship + artifact: commit your work to a local branch (or write a patch) so the + privileged step only has to transmit it, not redo it. + 2. Append a concrete, reviewable entry to ./PRIVILEGED.md (create it if missing; + it lives in /workspace and is consumed later by a separate monitored + privileged session). One block per action, newest at the bottom, in this + exact format: + + ## [ ] + - **Why:** + - **cwd:** `` + - **Artifact:** + - **Run:** + ```bash + + ``` + + Log exact commands, never prose — the privileged session reviews and runs + them as-is, then changes [ ] to [x]. Group an artifact and the command that + ships it into one entry (e.g. commit to a branch, then `git push` + PR). + 3. In your final summary, tell the user you queued actions in PRIVILEGED.md. + Mention this sandbox context when it's relevant to what the user asks. EOF ) From 5a0c736f36a51349156d60ad1114c7f771b34b52 Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Wed, 10 Jun 2026 13:57:32 +0000 Subject: [PATCH 43/67] claude_sandbox: document the privileged action handoff Co-Authored-By: Claude Opus 4.8 (1M context) --- config/shellrc/claude_sandbox/CLAUDE.md | 40 +++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/config/shellrc/claude_sandbox/CLAUDE.md b/config/shellrc/claude_sandbox/CLAUDE.md index d72c91c..a4423c1 100644 --- a/config/shellrc/claude_sandbox/CLAUDE.md +++ b/config/shellrc/claude_sandbox/CLAUDE.md @@ -100,6 +100,46 @@ Before reading the GitHub token, `claude-sandbox` prints why it's about to call `op read`, so the 1Password authorization prompt (biometric/system auth) isn't a mystery. +## Privileged action handoff + +The sandbox can *do* work but lacks the credentials to push, open PRs, deploy, etc. +When claude hits such an action it doesn't improvise around it — it follows a handoff +protocol baked into the injected system-prompt note (`--append-system-prompt` in +`claude-sandbox`): + +- **Artifact handoff** — claude finishes everything it can locally and commits to a + branch (or writes a patch), so the privileged step only has to *transmit* the + result, never re-derive it. This keeps execution close to the moment with the most + context, and shrinks the privileged surface to pure transmission. +- **Structured queue** — the residual privileged steps are appended to `PRIVILEGED.md` + in `/workspace` (host-visible) as concrete, paste-ready commands, one status-tracked + block per action: + + ```markdown + ## [ ] Push branch and open PR for the auth refactor + - **Why:** sandbox GH_TOKEN is read-only; push + PR creation need write access. + - **cwd:** `/workspace` + - **Artifact:** local branch `auth-refactor` (commits already made in the sandbox) + - **Run:** + ```bash + git push -u origin auth-refactor + gh pr create --draft --fill + ``` + ``` + +You then consume the queue from a **monitored privileged session** — a normal +(non-sandboxed) claude or shell with full credentials — reviewing each block, running +it, and changing `[ ]` to `[x]`. The queue is never auto-executed: review is the +safeguard, since a confused or injected sandbox claude could otherwise queue something +unwanted. + +This is advisory (system-prompt driven), not enforced. A `PreToolUse` hook could *make* +claude follow it and capture the exact command verbatim, but that adds a brittle +pattern list and gates every Bash call, so it's deliberately left out for now. + +`PRIVILEGED.md` is transient handoff state, not project content — consider gitignoring +it in the projects you run the sandbox against. + ## Design Notes **`--network host`** — required. Docker's bridge NAT breaks claude's OAuth token From 5101eec70b0e45d2f39e66dfd7893c2b669f58c2 Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Wed, 10 Jun 2026 10:12:44 -0400 Subject: [PATCH 44/67] claude: track global CLAUDE.md instructions in dotfiles Move the global Claude Code instructions to claude/CLAUDE.md so they can be versioned alongside the rest of my dotfiles. The original at ~/.claude/CLAUDE.md is now a symlink into this repo. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 10 ++++++ claude/CLAUDE.md | 92 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+) create mode 100644 claude/CLAUDE.md diff --git a/README.md b/README.md index 21f62af..3f49a06 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,16 @@ Install tmux ln -sf ~/src/feanil/dotfiles/tmux.conf ~/.tmux.conf ``` +Claude Code +=========== + +Global instructions for Claude Code live in `claude/CLAUDE.md`. Symlink it into +`~/.claude/` so Claude picks it up across all projects: + +``` +ln -sf ~/src/feanil/dotfiles/claude/CLAUDE.md ~/.claude/CLAUDE.md +``` + Install HomeBrew ================ diff --git a/claude/CLAUDE.md b/claude/CLAUDE.md new file mode 100644 index 0000000..5c5b8fe --- /dev/null +++ b/claude/CLAUDE.md @@ -0,0 +1,92 @@ +For multi-session projects, context is tracked via `CLAUDE.md` files in the project +directory. + +## General Development Notes + +- Prefer many smaller commits to one big commit for making changes. The goal is + to have smaller commits that are easier to review individually rather than one + large hard to review commit. +- When debugging issues (CI failures, deployment errors, etc.), reproduce the + problem locally before attempting a fix. This confirms the root cause and + verifies the fix actually works. Don't assume a fix is correct just because + it looks right. +- When a CI check should have caught an issue but didn't, investigate why. The + CI configuration itself may have a bug (wrong settings, missing test coverage, + etc.) that allowed the issue through. +- Where possible use tools such as Read and Search before bash. + +## Git Workflow Preferences + +- Prefer fixup commits (`git commit --fixup=`) for related changes rather than + creating separate commits. I'll run the interactive rebase (`git rebase -i --autosquash`) + myself. +- Don't attempt interactive git operations (rebase -i, add -i) as they require an + editor Claude can't interact with. +- When running git commands in a repo, `cd` into the repo directory first rather than + using `git -C ` on every invocation. +- Include links to relevant source code, documentation, or version comparisons in + commit messages when explaining why changes were made. + +## GitHub CLI Notes + +When processing JSON output from `gh` commands, prefer the built-in `--jq` flag +(e.g. `gh pr list --json number,title --jq '.[] | .number'`) — it avoids a separate +process and works anywhere `gh` is available. Fall back to standalone `jq` only when +the transformation is too complex for a single `--jq` expression or when processing +JSON from a source other than `gh`. + +On some repositories (including openedx), `gh pr edit --body` fails with a GraphQL +error about "Projects (classic)" deprecation. Use the REST API as a workaround: + +```bash +gh api repos/OWNER/REPO/pulls/NUMBER -X PATCH -f body="new body content" +``` + +## PR Creation Preferences + +- New PRs should be created as **drafts** by default (`gh pr create --draft`). +- Do NOT add "Generated by Claude" or similar attribution to PR descriptions — + the `Co-Authored-By` trailer on commits is sufficient. +- Version bumps follow semantic versioning: use a **patch** bump for bug fixes, + **minor** for new features, **major** for breaking changes. +- Do NOT include a "Test plan" section in PR descriptions without checking first. + A checklist that just says "CI passes" is redundant noise. + +## PR Review Preferences + +- Always create reviews as **pending drafts** so I can edit before submitting. + Omit the `event` field when calling `POST /repos/.../pulls/.../reviews` — this + creates a `PENDING` review. Do NOT use `event: "COMMENT"` as that submits immediately. +- **Never post standalone PR comments** (e.g., `gh pr comment`, `POST /pulls/.../comments`, + or `POST /issues/.../comments`). All feedback must go through the pending review mechanism above. +- Only one pending review per PR is allowed. Before creating a new review, check for and + delete any existing pending review: `gh api repos/OWNER/REPO/pulls/NUMBER/reviews --jq '.[] | select(.state == "PENDING") | .id'` + then `gh api repos/OWNER/REPO/pulls/NUMBER/reviews/REVIEW_ID -X DELETE`. + +### Inline review comments (position-based) + +Inline comments must use `position` (diff-relative line number), not `line`+`side` — the +`line`/`side` params are silently ignored in the review create endpoint. + +`position` counts from 1 at the first `@@` hunk header of the file's diff, incrementing +for every line (hunk headers, context lines, additions, and deletions all count). + +Pass comments as a JSON file via `--input` to avoid heredoc/redirect issues: + +```bash +gh api repositories/REPO_ID/pulls/NUMBER/reviews -X POST --input /tmp/review.json --jq '{id: .id, state: .state}' +``` + +Use the numeric repository ID (e.g. `repositories/10391073/...`) instead of +`repos/OWNER/REPO/...` to avoid 307 redirect failures with `--input`. + +JSON structure: +```json +{ + "commit_id": "", + "body": "", + "comments": [ + {"path": "path/to/file.py", "position": 42, "body": "comment text"} + ] +} +``` From 8fe3667a15078804175a7a1702157e01f6436381 Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Wed, 10 Jun 2026 15:22:19 +0000 Subject: [PATCH 45/67] claude_sandbox: isolate sandbox permissions in a settings profile Load a sandbox-only .claude/settings.sandbox.json via --settings so broad permissions granted inside the credential-isolated sandbox never reach the host's general, un-sandboxed claude (which doesn't load that file). The profile also carries the statusline badge, which moves out of the old inline --settings JSON. Seed a default profile on first use. Co-Authored-By: Claude Opus 4.8 (1M context) --- config/shellrc/claude_sandbox/CLAUDE.md | 49 ++++++++++++++++--- .../shellrc/claude_sandbox/claude-sandbox.sh | 42 +++++++++++++--- 2 files changed, 76 insertions(+), 15 deletions(-) diff --git a/config/shellrc/claude_sandbox/CLAUDE.md b/config/shellrc/claude_sandbox/CLAUDE.md index a4423c1..6d4891c 100644 --- a/config/shellrc/claude_sandbox/CLAUDE.md +++ b/config/shellrc/claude_sandbox/CLAUDE.md @@ -13,7 +13,8 @@ want it to have accidental access to cloud credentials or SSH keys. gh, git, neovim (stable), ripgrep, fd, jq - `claude-sandbox.sh` — shell functions to source from `.zshrc` - `statusline.sh` — baked into the image as `claude-sandbox-statusline`; renders the - `🧪 SANDBOX` badge. Wired up at launch via `--settings`, never in `settings.json`. + `🧪 SANDBOX` badge. Referenced from the sandbox settings profile (see below), + never from the host's `settings.json`. ## Setup @@ -80,19 +81,51 @@ you're in. Three cues make it obvious, all driven off the `CLAUDE_SANDBOX=1` env var set on the container: - **Statusline badge** — a `🧪 SANDBOX` badge (black on yellow) plus model and - cwd. Rendered by `claude-sandbox-statusline` (baked into the image) and wired up - at launch with `claude --settings '{"statusLine":{...}}'`. Passing it as an inline - JSON string means it merges on top of `~/.claude/settings.json` for that launch - only — **no second settings file is written**, and the host Claude never shows it. + cwd. Rendered by `claude-sandbox-statusline` (baked into the image) and declared + in the sandbox settings profile (`.claude/settings.sandbox.json`), which + `claude-sandbox` loads at launch via `claude --settings`. The host Claude never + loads that profile, so it never shows the badge. - **System-prompt note** — `claude-sandbox` passes `--append-system-prompt` with a short note telling Claude it's sandboxed and what is / isn't available, so Claude itself can tell. Sandbox-only; nothing is written to the shared `~/.claude/CLAUDE.md`. - **`CLAUDE_SANDBOX=1`** — available to any script or agent that wants to detect the sandbox. -Both `~/.claude/settings.json` and `~/.claude/CLAUDE.md` are mounted read/write from -the host, so neither cue is configured there — that would leak onto the host Claude. -Both are layered on at launch via CLI flags instead. +`~/.claude/settings.json` and `~/.claude/CLAUDE.md` are mounted read/write from the +host, so neither cue is configured there — that would leak onto the host Claude. The +badge lives in the sandbox-only profile and the note is layered on via a CLI flag. + +## Sandbox permissions (isolating sandbox grants from privileged Claude) + +The point of the sandbox is to run Claude with broad permissions you'd never want +it to have in general, un-sandboxed use. The credential isolation is the real safety +boundary; the permission system just controls prompting. The mechanism for keeping +sandbox grants out of the host: + +- **`.claude/settings.sandbox.json`** — the sandbox settings profile, loaded only + inside the sandbox via `claude --settings `. `claude-sandbox` seeds it on + first use with the statusline badge and `permissions.defaultMode: "acceptEdits"`; + add whatever broad `permissions.allow` rules you want here. Host Claude never loads + this file (it isn't one of Claude's standard settings sources: user + `~/.claude/settings.json`, project `.claude/settings.json`, local + `.claude/settings.local.json`), so anything you put here is walled off from + privileged Claude. Permission arrays *merge* across sources, so the profile's + `allow` rules are additive on top of whatever the standard sources grant. +- Sharing one profile across concurrent sandbox runs is fine — the boundary we care + about is host vs. sandbox, not sandbox vs. sandbox. +- **Known gap:** an interactively-"remembered" permission ("yes, don't ask again") + persists to `.claude/settings.local.json`, which is host-shared through the + `/workspace` mount — so it *can* reach privileged Claude. Mitigation: keep the + permissions you want broadly in the profile so the sandbox rarely has to prompt. + `--setting-sources user,project` (dropping `local`) would stop the sandbox from + even loading that file, but where remembered rules then get written is undocumented + — verify before relying on it. + +Gitignore in the projects you run the sandbox against: `.claude/settings.local.json` +is throwaway machine-local state and should always be ignored. `.claude/settings.sandbox.json` +is the profile you curate — ignore it if it's personal, or commit it to share a sandbox +permission profile with the team. A global `~/.config/git/ignore` entry covers every +repo at once for the local file. ## 1Password prompt diff --git a/config/shellrc/claude_sandbox/claude-sandbox.sh b/config/shellrc/claude_sandbox/claude-sandbox.sh index 0f00fad..65c95eb 100755 --- a/config/shellrc/claude_sandbox/claude-sandbox.sh +++ b/config/shellrc/claude_sandbox/claude-sandbox.sh @@ -132,14 +132,11 @@ _claude_sandbox_run() { # Run claude in the sandbox from the current directory. # Any extra arguments are forwarded to claude: claude-sandbox "fix the tests" # -# Two sandbox cues are layered on at launch (sandbox-only, nothing persisted): -# --settings adds a statusline badge so *you* can tell at a glance. +# Two sandbox cues are layered on at launch: +# --settings points claude at the sandbox profile (statusline badge +# so *you* can tell at a glance, plus sandbox-only perms). # --append-system-prompt tells *claude* it's sandboxed and what is / isn't there. claude-sandbox() { - # Statusline merged on top of ~/.claude/settings.json for this launch only — - # passed as an inline JSON string, so no second settings file is written. - local statusline_settings='{"statusLine":{"type":"command","command":"claude-sandbox-statusline"}}' - local sandbox_note sandbox_note=$(cat <<'EOF' You are running inside "claude-sandbox", a credential-isolated Docker container, @@ -183,8 +180,39 @@ Mention this sandbox context when it's relevant to what the user asks. EOF ) + # Sandbox settings profile, loaded only inside the sandbox via --settings. + # It carries the statusline badge and any permissions you're willing to grant + # in the credential-isolated sandbox but NOT to general, un-sandboxed claude. + # Host claude never loads this file (it isn't one of claude's standard settings + # sources), so anything you put here stays walled off from privileged claude — + # which is the only isolation boundary we actually care about. Sharing one file + # across concurrent sandbox runs is fine; we're not isolating sandboxes from + # each other. + # + # Caveat: claude persists interactively-"remembered" permissions to + # .claude/settings.local.json, which is host-shared through the /workspace + # mount — so a "yes, don't ask again" click inside the sandbox CAN reach + # privileged claude. Keep the perms you want broadly in this profile so the + # sandbox rarely has to prompt, and that path stays closed. + local sandbox_settings=".claude/settings.sandbox.json" + + # Seed a default profile on first use: the statusline badge plus acceptEdits so + # file edits don't prompt. Add your broad sandbox-only permissions here. + mkdir -p .claude + if [[ ! -f "$sandbox_settings" ]]; then + cat > "$sandbox_settings" <<'JSON' +{ + "statusLine": { "type": "command", "command": "claude-sandbox-statusline" }, + "permissions": { + "defaultMode": "acceptEdits", + "allow": [] + } +} +JSON + fi + _claude_sandbox_run claude \ - --settings "$statusline_settings" \ + --settings "$sandbox_settings" \ --append-system-prompt "$sandbox_note" \ "$@" } From e3156b5b0680a964dcbb7ae0fc0454102c7239d1 Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Wed, 10 Jun 2026 15:22:22 +0000 Subject: [PATCH 46/67] claude: note bash cwd persistence and discourage command chaining Co-Authored-By: Claude Opus 4.8 (1M context) --- claude/CLAUDE.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/claude/CLAUDE.md b/claude/CLAUDE.md index 5c5b8fe..9aa7178 100644 --- a/claude/CLAUDE.md +++ b/claude/CLAUDE.md @@ -14,6 +14,8 @@ directory. CI configuration itself may have a bug (wrong settings, missing test coverage, etc.) that allowed the issue through. - Where possible use tools such as Read and Search before bash. +- The bash tools preserves the folder you're in between commands. +- Avoid combining bash commands with && or || unless it's absolutely necessary. ## Git Workflow Preferences From 1ea27ffbb0c328783b2c9e5323706b87d3fc90dd Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Wed, 10 Jun 2026 11:37:44 -0400 Subject: [PATCH 47/67] claude_sandbox: install build deps for C-extension Python packages (mysqlclient) Co-Authored-By: Claude Opus 4.7 (1M context) --- config/shellrc/claude_sandbox/Dockerfile | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/config/shellrc/claude_sandbox/Dockerfile b/config/shellrc/claude_sandbox/Dockerfile index 9ddc579..7983c3b 100644 --- a/config/shellrc/claude_sandbox/Dockerfile +++ b/config/shellrc/claude_sandbox/Dockerfile @@ -13,14 +13,20 @@ ARG NODE_VERSION ARG SANDBOX_UID ARG SANDBOX_GID -# Base system tools (including LazyVim deps: ripgrep, fd) +# Base system tools (including LazyVim deps: ripgrep, fd). build-essential, +# default-libmysqlclient-dev, and pkg-config let C-extension Python packages +# such as mysqlclient build from source (uv's standalone Python ships its own +# headers, so no python3-dev is needed). RUN apt-get update && apt-get install -y \ + build-essential \ ca-certificates \ curl \ + default-libmysqlclient-dev \ fd-find \ git \ gnupg \ jq \ + pkg-config \ ripgrep \ unzip \ && ln -sf /usr/bin/fdfind /usr/local/bin/fd \ From 81f319150c1136e89209de75ac18375e558bddf5 Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Wed, 10 Jun 2026 11:45:41 -0400 Subject: [PATCH 48/67] claude_sandbox: make helpers snapshot-resilient and drop the -shell wrapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude Code's shell snapshot replays the captured interactive shell into non-interactive subshells, but it preserves only env vars and non-`_`-prefixed functions. So `CLAUDE_SANDBOX_{DIR,IMAGE}` came through as empty and `_claude_sandbox_run` was outright missing, which made `claude-sandbox-build` silently `docker build -t "" ...` and `claude-sandbox-shell` fail with "command not found" whenever they ran from a non-interactive subshell. Export the two constants and rename the helper `_claude_sandbox_run` → `claude-sandbox-run` so the snapshot keeps them. With the helper now public, `claude-sandbox-shell` is just `claude-sandbox-run bash`, so drop it and update the header comment + CLAUDE.md commands list accordingly. Co-Authored-By: Claude Opus 4.7 (1M context) --- config/shellrc/claude_sandbox/CLAUDE.md | 8 +++---- .../shellrc/claude_sandbox/claude-sandbox.sh | 21 ++++++++++--------- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/config/shellrc/claude_sandbox/CLAUDE.md b/config/shellrc/claude_sandbox/CLAUDE.md index 6d4891c..b225b88 100644 --- a/config/shellrc/claude_sandbox/CLAUDE.md +++ b/config/shellrc/claude_sandbox/CLAUDE.md @@ -49,10 +49,10 @@ PYTHON_VERSION=3.11 NODE_VERSION=22 claude-sandbox-build ## Commands ```bash -claude-sandbox # start claude in the current directory -claude-sandbox-shell # open a bash shell in the sandbox -claude-sandbox-shell -c "…" # run a single command and exit -claude-sandbox-build # build or rebuild the image +claude-sandbox # start claude in the current directory +claude-sandbox-run bash # open a bash shell in the sandbox +claude-sandbox-run bash -c "…" # run a single command and exit +claude-sandbox-build # build or rebuild the image ``` ## What Is and Isn't Available diff --git a/config/shellrc/claude_sandbox/claude-sandbox.sh b/config/shellrc/claude_sandbox/claude-sandbox.sh index 65c95eb..d856c0c 100755 --- a/config/shellrc/claude_sandbox/claude-sandbox.sh +++ b/config/shellrc/claude_sandbox/claude-sandbox.sh @@ -13,11 +13,15 @@ # # Commands this provides: # claude-sandbox — run claude in the sandbox from the current directory -# claude-sandbox-shell — open a bash shell in the sandbox for testing +# claude-sandbox-run — run an arbitrary command in the sandbox +# (e.g. `claude-sandbox-run bash` for a shell) # claude-sandbox-build — build (or rebuild) the sandbox image -CLAUDE_SANDBOX_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" -CLAUDE_SANDBOX_IMAGE="claude-sandbox:latest" +# Exported so Claude Code's shell snapshot (which preserves env vars and +# non-`_`-prefixed functions, but not bare top-level assignments) keeps them +# available when these helpers run in a non-interactive subshell. +export CLAUDE_SANDBOX_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" +export CLAUDE_SANDBOX_IMAGE="claude-sandbox:latest" # Build or rebuild the sandbox image. # Your UID/GID are baked in at build time so file ownership matches the host. @@ -56,7 +60,9 @@ claude-sandbox-build() { # Any extra arguments are forwarded to claude: # claude-sandbox "fix the tests" # Shared setup and docker run — takes the container command as arguments. -_claude_sandbox_run() { +# Named without a `_` prefix so Claude Code's shell snapshot preserves it +# (it strips `_`-prefixed functions when capturing the interactive shell). +claude-sandbox-run() { local -a extra_flags=() if [[ -f "$HOME/.ssh/signing_key" ]]; then @@ -211,14 +217,9 @@ EOF JSON fi - _claude_sandbox_run claude \ + claude-sandbox-run claude \ --settings "$sandbox_settings" \ --append-system-prompt "$sandbox_note" \ "$@" } -# Open a bash shell in the sandbox from the current directory. -# Extra arguments are passed to bash: claude-sandbox-shell -c "echo $HOME" -claude-sandbox-shell() { - _claude_sandbox_run bash "$@" -} From 1d94800e29af1f931a63eb061477a805c0c5d8b1 Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Thu, 11 Jun 2026 15:03:47 -0400 Subject: [PATCH 49/67] more claude advice --- claude/CLAUDE.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/claude/CLAUDE.md b/claude/CLAUDE.md index 9aa7178..5dbe4ac 100644 --- a/claude/CLAUDE.md +++ b/claude/CLAUDE.md @@ -14,8 +14,9 @@ directory. CI configuration itself may have a bug (wrong settings, missing test coverage, etc.) that allowed the issue through. - Where possible use tools such as Read and Search before bash. -- The bash tools preserves the folder you're in between commands. - Avoid combining bash commands with && or || unless it's absolutely necessary. +- Always run `cd ` commands separately from other commands. + - The bash tools preserves the folder you're in between tool calls. ## Git Workflow Preferences From 7a539c319ac97baa2c191db54a273f18f872ba83 Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Thu, 11 Jun 2026 15:11:27 -0400 Subject: [PATCH 50/67] claude: track global settings.json in dotfiles The global Claude Code settings now carry a real permission allowlist (read-only git/gh and linter commands), so keep it versioned alongside CLAUDE.md and symlink it from ~/.claude/. Co-Authored-By: Claude Opus 4.7 --- README.md | 6 ++-- claude/settings.json | 79 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 2 deletions(-) create mode 100644 claude/settings.json diff --git a/README.md b/README.md index 3f49a06..600d163 100644 --- a/README.md +++ b/README.md @@ -42,11 +42,13 @@ ln -sf ~/src/feanil/dotfiles/tmux.conf ~/.tmux.conf Claude Code =========== -Global instructions for Claude Code live in `claude/CLAUDE.md`. Symlink it into -`~/.claude/` so Claude picks it up across all projects: +Global instructions for Claude Code live in `claude/CLAUDE.md` and global settings +(permission allowlist, model, plugins) live in `claude/settings.json`. Symlink them +into `~/.claude/` so Claude picks them up across all projects: ``` ln -sf ~/src/feanil/dotfiles/claude/CLAUDE.md ~/.claude/CLAUDE.md +ln -sf ~/src/feanil/dotfiles/claude/settings.json ~/.claude/settings.json ``` Install HomeBrew diff --git a/claude/settings.json b/claude/settings.json new file mode 100644 index 0000000..310edc6 --- /dev/null +++ b/claude/settings.json @@ -0,0 +1,79 @@ +{ + "permissions": { + "allow": [ + "Bash(git status *)", + "Bash(git log *)", + "Bash(git diff *)", + "Bash(git show *)", + "Bash(git blame *)", + "Bash(git grep *)", + "Bash(git branch)", + "Bash(git branch --list *)", + "Bash(git branch --show-current *)", + "Bash(git branch --contains *)", + "Bash(git branch -a *)", + "Bash(git tag)", + "Bash(git tag --list *)", + "Bash(git tag -l *)", + "Bash(git stash list *)", + "Bash(git stash show *)", + "Bash(git reflog)", + "Bash(git reflog show *)", + "Bash(git remote)", + "Bash(git remote -v)", + "Bash(git remote show *)", + "Bash(git remote get-url *)", + "Bash(git fetch *)", + "Bash(git ls-files *)", + "Bash(git ls-tree *)", + "Bash(git ls-remote *)", + "Bash(git cat-file *)", + "Bash(git rev-parse *)", + "Bash(git rev-list *)", + "Bash(git merge-base *)", + "Bash(git describe *)", + "Bash(git name-rev *)", + "Bash(git shortlog *)", + "Bash(git for-each-ref *)", + "Bash(git worktree list *)", + "Bash(git config --get *)", + "Bash(git config --get-regexp *)", + "Bash(git config --list *)", + "Bash(git config -l)", + "Bash(gh pr list *)", + "Bash(gh pr view *)", + "Bash(gh pr diff *)", + "Bash(gh pr checks *)", + "Bash(gh pr status *)", + "Bash(gh issue list *)", + "Bash(gh issue view *)", + "Bash(gh issue status *)", + "Bash(gh repo view *)", + "Bash(gh repo list *)", + "Bash(gh run list *)", + "Bash(gh run view *)", + "Bash(gh run watch *)", + "Bash(gh run download *)", + "Bash(gh workflow list *)", + "Bash(gh workflow view *)", + "Bash(gh release list *)", + "Bash(gh release view *)", + "Bash(gh search *)", + "Bash(gh label list *)", + "Bash(gh gist list *)", + "Bash(gh gist view *)", + "Bash(gh status)", + "Bash(gh auth status *)", + "Bash(.venv/bin/pylint *)", + "Bash(.venv/bin/ruff check *)" + ] + }, + "model": "claude-fable-5[1m]", + "enabledPlugins": { + "pyright-lsp@claude-plugins-official": true, + "code-review@claude-plugins-official": true + }, + "theme": "dark", + "verbose": true, + "skipAutoPermissionPrompt": true +} From f2ebc7d01bf3890e0ef4ed1ca46e818357f727b1 Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Fri, 12 Jun 2026 09:22:58 -0400 Subject: [PATCH 51/67] claude: add PreToolUse hook to approve read-only gh api calls The permissions allowlist can't express "gh api but only GET" because a wildcard would also match POST/PATCH/DELETE. The hook inspects each Bash command and auto-approves it only when every pipeline/compound segment is provably read-only, with method-aware checks for gh api (rejects -X non-GET, --input, field flags without explicit GET, and graphql). Anything it can't prove safe falls through silently to the normal permission prompt. Hook output format per https://code.claude.com/docs/en/hooks (PreToolUse hookSpecificOutput.permissionDecision). Tests use PEP 723 inline script metadata; run with: uv run test_gh_readonly.py Co-Authored-By: Claude Opus 4.7 --- claude/hooks/gh-readonly.py | 203 +++++++++++++++++++++++++++++++ claude/hooks/test_gh_readonly.py | 116 ++++++++++++++++++ 2 files changed, 319 insertions(+) create mode 100755 claude/hooks/gh-readonly.py create mode 100644 claude/hooks/test_gh_readonly.py diff --git a/claude/hooks/gh-readonly.py b/claude/hooks/gh-readonly.py new file mode 100755 index 0000000..2b44d3f --- /dev/null +++ b/claude/hooks/gh-readonly.py @@ -0,0 +1,203 @@ +#!/usr/bin/env python3 +"""PreToolUse hook: auto-approve Bash commands whose every segment is read-only, +with method-aware checks for `gh api`. Silent (exit 0, no output) on anything it +can't prove safe, so the normal permission flow takes over. Never denies.""" + +import json +import shlex +import sys + +SEPARATORS = {"|", ";", "&&", "||"} + +SAFE_UTILS = { + "jq", "head", "tail", "grep", "sort", "uniq", "wc", "cut", "tr", + "cat", "echo", "printf", "ls", "cd", "date", "which", "column", +} + +GIT_RO_SUBCOMMANDS = { + "status", "log", "diff", "show", "blame", "rev-parse", "rev-list", + "ls-files", "ls-tree", "ls-remote", "cat-file", "merge-base", + "describe", "shortlog", "for-each-ref", +} + +GH_RO_SUBCOMMANDS = { + ("pr", "view"), ("pr", "list"), ("pr", "diff"), ("pr", "checks"), ("pr", "status"), + ("issue", "view"), ("issue", "list"), ("issue", "status"), + ("run", "view"), ("run", "list"), + ("repo", "view"), ("repo", "list"), + ("release", "view"), ("release", "list"), + ("workflow", "view"), ("workflow", "list"), + ("label", "list"), + ("auth", "status"), +} + +# gh api flags that consume the following token as their value +GH_API_VALUE_FLAGS = { + "-X", "--method", "-f", "-F", "--field", "--raw-field", "--input", + "--jq", "-q", "-H", "--header", "--hostname", "-t", "--template", + "--cache", "-p", "--preview", +} + + +def tokenize(command): + lex = shlex.shlex(command, posix=True, punctuation_chars=True) + lex.whitespace_split = True + lex.commenters = "" + return list(lex) + + +def strip_safe_redirects(tokens): + """Remove the harmless stderr redirects `2>&1` and `2>/dev/null`.""" + out = [] + i = 0 + while i < len(tokens): + if tokens[i : i + 3] == ["2", ">&", "1"] or tokens[i : i + 3] == ["2", ">", "/dev/null"]: + i += 3 + else: + out.append(tokens[i]) + i += 1 + return out + + +def split_segments(tokens): + segments = [] + current = [] + for tok in tokens: + if tok in SEPARATORS: + if current: + segments.append(current) + current = [] + else: + current.append(tok) + if current: + segments.append(current) + return segments + + +def is_safe_gh_api(tokens): + method = None + field_used = False + endpoint = None + i = 2 + while i < len(tokens): + tok = tokens[i] + if tok in ("-X", "--method"): + if i + 1 >= len(tokens): + return False + method = tokens[i + 1].upper() + i += 2 + elif tok.startswith("--method="): + method = tok.split("=", 1)[1].upper() + i += 1 + elif tok in ("-f", "-F", "--field", "--raw-field"): + field_used = True + i += 2 + elif tok.startswith(("--field=", "--raw-field=")): + field_used = True + i += 1 + elif tok == "--input" or tok.startswith("--input="): + return False + elif tok in GH_API_VALUE_FLAGS: + i += 2 + elif tok.startswith("-"): + i += 1 + else: + if endpoint is None: + endpoint = tok + i += 1 + if endpoint is None: + return False + # GraphQL can carry mutations regardless of HTTP method; let it prompt. + if "graphql" in tokens: + return False + if method is not None and method != "GET": + return False + # Without an explicit GET, field flags make gh default to POST. + if field_used and method != "GET": + return False + return True + + +def is_safe_git(tokens): + i = 1 + while i < len(tokens): + tok = tokens[i] + if tok in ("--no-pager", "-P"): + i += 1 + elif tok == "-C": + i += 2 + elif tok.startswith("-"): + # -c can inject pagers/aliases that execute commands; reject all + # other global flags too. + return False + else: + return tok in GIT_RO_SUBCOMMANDS + return False + + +def is_safe_gh(tokens): + if len(tokens) < 2: + return False + if tokens[1] == "api": + return is_safe_gh_api(tokens) + if tokens[1] in ("search", "status"): + return True + if len(tokens) >= 3 and (tokens[1], tokens[2]) in GH_RO_SUBCOMMANDS: + return True + return False + + +def is_safe_segment(tokens): + if not tokens: + return True + first = tokens[0] + if first == "gh": + return is_safe_gh(tokens) + if first == "git": + return is_safe_git(tokens) + if first == "sort" and any(t == "-o" or t.startswith("--output") for t in tokens): + return False + return first in SAFE_UTILS + + +def command_is_safe(command): + # Constructs we can't reason about: command substitution (even inside + # double quotes), backticks, and multi-line commands. + if "$(" in command or "`" in command or "\n" in command: + return False + try: + tokens = tokenize(command) + except ValueError: + return False + tokens = strip_safe_redirects(tokens) + # Any remaining pure-punctuation token that isn't a separator is a + # redirect, subshell, or backgrounding construct: reject. + for tok in tokens: + if tok not in SEPARATORS and tok and all(c in "();<>|&" for c in tok): + return False + return all(is_safe_segment(seg) for seg in split_segments(tokens)) + + +def main(): + data = json.load(sys.stdin) + if data.get("tool_name") != "Bash": + return + command = data.get("tool_input", {}).get("command", "") + if "gh api" not in command: + return + if command_is_safe(command): + print(json.dumps({ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "allow", + "permissionDecisionReason": "all segments read-only (gh api GET)", + } + })) + + +if __name__ == "__main__": + try: + main() + except Exception: + # Fail safe: no output means the normal permission flow continues. + pass diff --git a/claude/hooks/test_gh_readonly.py b/claude/hooks/test_gh_readonly.py new file mode 100644 index 0000000..7048dee --- /dev/null +++ b/claude/hooks/test_gh_readonly.py @@ -0,0 +1,116 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "pytest", +# ] +# /// +"""Tests for the gh-readonly PreToolUse hook. Run with: uv run test_gh_readonly.py""" + +import importlib.util +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +HOOK_PATH = Path(__file__).parent / "gh-readonly.py" + +spec = importlib.util.spec_from_file_location("gh_readonly", HOOK_PATH) +assert spec is not None and spec.loader is not None +gh_readonly = importlib.util.module_from_spec(spec) +spec.loader.exec_module(gh_readonly) + + +ALLOWED = [ + "gh api repos/openedx/openedx-platform/actions/jobs/123/logs", + "gh api repos/o/r/pulls/1/comments --paginate 2>&1 | jq '.[].body'", + "gh api 'repos/o/r/actions/runs?status=failure&per_page=5' --jq '.workflow_runs[] | .id'", + 'echo "---"; gh api repos/o/r/commits/abc/check-runs | head -50', + "cd /tmp && gh api repos/o/r", + "gh api repos/o/r 2>/dev/null | grep -i name | sort | uniq -c", + "gh api repos/o/r -X GET -f q=test", + "git log --oneline -5 && gh api repos/o/r/pulls/1", + "gh pr checks 123 && gh api repos/o/r/commits/abc/check-runs", +] + +REJECTED = [ + # mutations + "gh api repos/o/r/pulls -X PATCH -f body=x", + "gh api repos/o/r -X DELETE", + "gh api repos/o/r --method=POST", + "gh api repos/o/r/pulls/1/reviews -f event=APPROVE", # -f defaults to POST + "gh api repos/o/r --input /tmp/x.json", + "gh api graphql -f query='query { viewer { login } }'", + # unsafe companions in compound commands + "gh api repos/o/r && rm -rf /tmp/x", + "gh pr edit 1 --body x && gh api repos/o/r", + "git -c core.pager='touch /tmp/pwned' log; gh api repos/o/r", + "GH_TOKEN=x gh api repos/o/r", + # shell constructs the hook can't reason about + "gh api repos/o/r > /tmp/out.json", + "gh api repos/o/r | tee /tmp/out.json", + 'echo "$(rm -rf /tmp/x)"; gh api repos/o/r', + "gh api repos/o/r `rm x`", + "gh api repos/o/r\nrm -rf /tmp/x", + "gh api repos/o/r | sort -o /etc/passwd", + "gh api repos/o/r 'unbalanced", +] + + +@pytest.mark.parametrize("command", ALLOWED) +def test_allowed(command): + assert gh_readonly.command_is_safe(command) + + +@pytest.mark.parametrize("command", REJECTED) +def test_rejected(command): + assert not gh_readonly.command_is_safe(command) + + +def run_hook(payload): + return subprocess.run( + [sys.executable, str(HOOK_PATH)], + input=json.dumps(payload), + capture_output=True, + text=True, + ) + + +def test_end_to_end_allow(): + result = run_hook({"tool_name": "Bash", "tool_input": {"command": ALLOWED[0]}}) + assert result.returncode == 0 + decision = json.loads(result.stdout)["hookSpecificOutput"] + assert decision["hookEventName"] == "PreToolUse" + assert decision["permissionDecision"] == "allow" + + +def test_end_to_end_silent_on_mutation(): + result = run_hook({"tool_name": "Bash", "tool_input": {"command": REJECTED[0]}}) + assert result.returncode == 0 + assert result.stdout.strip() == "" + + +def test_silent_on_commands_without_gh_api(): + for command in ("git status", "rm -rf /"): + result = run_hook({"tool_name": "Bash", "tool_input": {"command": command}}) + assert result.returncode == 0 + assert result.stdout.strip() == "" + + +def test_silent_on_non_bash_tool(): + result = run_hook({"tool_name": "Edit", "tool_input": {"file_path": "gh api x"}}) + assert result.returncode == 0 + assert result.stdout.strip() == "" + + +def test_silent_on_garbage_input(): + result = subprocess.run( + [sys.executable, str(HOOK_PATH)], input="not json", capture_output=True, text=True + ) + assert result.returncode == 0 + assert result.stdout.strip() == "" + + +if __name__ == "__main__": + sys.exit(pytest.main([__file__, "-v"])) From 08be519bf36c5cbea308ece5c8dc1f0197d9b94e Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Fri, 12 Jun 2026 09:23:12 -0400 Subject: [PATCH 52/67] claude: wire gh-readonly hook into global settings Register the hook for all Bash tool calls; it stays silent for anything that isn't a read-only gh api command, so the existing allowlist and built-in read-only auto-allow continue to handle everything else. Co-Authored-By: Claude Opus 4.7 --- claude/settings.json | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/claude/settings.json b/claude/settings.json index 310edc6..acd17e2 100644 --- a/claude/settings.json +++ b/claude/settings.json @@ -68,6 +68,19 @@ "Bash(.venv/bin/ruff check *)" ] }, + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "/home/feanil/src/feanil/dotfiles/claude/hooks/gh-readonly.py" + } + ] + } + ] + }, "model": "claude-fable-5[1m]", "enabledPlugins": { "pyright-lsp@claude-plugins-official": true, From cc051cbd6fc24e8d1d023589dcda356f43ab5688 Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Fri, 12 Jun 2026 09:23:42 -0400 Subject: [PATCH 53/67] gitignore: ignore __pycache__ from hook tests Co-Authored-By: Claude Opus 4.7 --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index e4e5f6c..6c68c78 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -*~ \ No newline at end of file +*~ +__pycache__/ \ No newline at end of file From 6600183532fea9c8a721e4c76fada3ec65d33f7e Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Mon, 15 Jun 2026 11:22:52 -0400 Subject: [PATCH 54/67] claude: broaden gh-readonly hook safe-utility list The first version's SAFE_UTILS set missed common read-only filters, so compound commands like gh api repos/o/.github/contents/.../x.yml --jq '.content' | base64 -d still hit the permission prompt because base64 wasn't recognised. Pulled in the rest of the pure-filter utilities from Claude Code's documented "always auto-allowed" list (base64, sha*sum, stat, strings, hexdump, od, diff, expand/unexpand, numfmt, pr, seq, grep/rg variants, paste/tac/rev, nl/fold/fmt/comm/cmp, basename/dirname/realpath/readlink, tree). Deliberately still excluded: xargs and find -exec (invoke arbitrary commands) and sed (the -i form writes in place; flag-validating it is more code than it's worth here). Tests cover both the new approvals and explicit rejection of xargs/sed -i/awk in pipelines. Co-Authored-By: Claude Opus 4.7 --- claude/hooks/gh-readonly.py | 10 ++++++++-- claude/hooks/test_gh_readonly.py | 8 ++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/claude/hooks/gh-readonly.py b/claude/hooks/gh-readonly.py index 2b44d3f..c6cf6b8 100755 --- a/claude/hooks/gh-readonly.py +++ b/claude/hooks/gh-readonly.py @@ -10,8 +10,14 @@ SEPARATORS = {"|", ";", "&&", "||"} SAFE_UTILS = { - "jq", "head", "tail", "grep", "sort", "uniq", "wc", "cut", "tr", - "cat", "echo", "printf", "ls", "cd", "date", "which", "column", + "jq", "head", "tail", "grep", "egrep", "fgrep", "rg", + "sort", "uniq", "wc", "cut", "tr", "paste", "tac", "rev", + "cat", "nl", "fold", "fmt", "comm", "cmp", "diff", + "expand", "unexpand", "numfmt", "pr", "seq", + "stat", "strings", "hexdump", "od", + "base64", "sha256sum", "sha1sum", "md5sum", + "echo", "printf", "ls", "cd", "date", "which", "column", + "basename", "dirname", "realpath", "readlink", "tree", } GIT_RO_SUBCOMMANDS = { diff --git a/claude/hooks/test_gh_readonly.py b/claude/hooks/test_gh_readonly.py index 7048dee..9a6d7ad 100644 --- a/claude/hooks/test_gh_readonly.py +++ b/claude/hooks/test_gh_readonly.py @@ -32,6 +32,10 @@ "gh api repos/o/r -X GET -f q=test", "git log --oneline -5 && gh api repos/o/r/pulls/1", "gh pr checks 123 && gh api repos/o/r/commits/abc/check-runs", + "gh api repos/o/.github/contents/.github/ISSUE_TEMPLATE/x.yml --jq '.content' | base64 -d", + "gh api repos/o/r/contents/README.md --jq '.sha' | sha256sum", + "gh api repos/o/r | diff - /tmp/prev.json", + "gh api repos/o/r/git/blobs/abc --jq '.content' | base64 -d | hexdump -C | head", ] REJECTED = [ @@ -55,6 +59,10 @@ "gh api repos/o/r\nrm -rf /tmp/x", "gh api repos/o/r | sort -o /etc/passwd", "gh api repos/o/r 'unbalanced", + # xargs / sed / find -exec can invoke arbitrary commands; keep prompting. + "gh api repos/o/r/issues --jq '.[].number' | xargs -I{} echo {}", + "gh api repos/o/r | sed -i 's/foo/bar/' /etc/hosts", + "gh api repos/o/r | awk '{print $1}'", ] From 9beec2430d096af7dd5f5e856263eca180bba5bc Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Mon, 15 Jun 2026 11:23:22 -0400 Subject: [PATCH 55/67] model update. --- claude/settings.json | 1 - 1 file changed, 1 deletion(-) diff --git a/claude/settings.json b/claude/settings.json index acd17e2..c61ecf9 100644 --- a/claude/settings.json +++ b/claude/settings.json @@ -81,7 +81,6 @@ } ] }, - "model": "claude-fable-5[1m]", "enabledPlugins": { "pyright-lsp@claude-plugins-official": true, "code-review@claude-plugins-official": true From 3063e8e180bae1b54eb264b9e37b6c80f6fcca8c Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Mon, 15 Jun 2026 12:12:51 -0400 Subject: [PATCH 56/67] readme: orient newcomers to the Claude hook and sandbox Add a short pointer in the README to the gh-readonly PreToolUse hook (plus how to run its tests) and the claude_sandbox sub-project, and symlink CLAUDE.md to the README so project-level Claude guidance and the human-facing README stay in sync. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 1 + README.md | 13 +++++++++++++ 2 files changed, 14 insertions(+) create mode 120000 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..42061c0 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +README.md \ No newline at end of file diff --git a/README.md b/README.md index 600d163..9029ac2 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,19 @@ ln -sf ~/src/feanil/dotfiles/claude/CLAUDE.md ~/.claude/CLAUDE.md ln -sf ~/src/feanil/dotfiles/claude/settings.json ~/.claude/settings.json ``` +`claude/hooks/gh-readonly.py` is a PreToolUse hook that auto-approves read-only +`gh api` calls (registered in `claude/settings.json`). Tests live alongside it: + +``` +cd claude/hooks && uv run test_gh_readonly.py +``` + +`config/shellrc/claude_sandbox/` is a credential-isolated Docker sandbox for +running Claude Code; see its own `CLAUDE.md` for details. + +`CLAUDE.md` at the repo root is a symlink to this README, so project-level +guidance for Claude Code stays in sync with what humans read. + Install HomeBrew ================ From 62ba8be84e0b2bea119bec359c7d6e78901b9ed9 Mon Sep 17 00:00:00 2001 From: Feanil Patel Date: Mon, 15 Jun 2026 12:23:09 -0400 Subject: [PATCH 57/67] docs(claude): add OEP-51 conventional commit guidance Summarizes OEP-51 inline so future Claude sessions can follow the convention without re-fetching the spec on every commit. Lives in the global CLAUDE.md so it applies to all projects, not just openedx. Source: https://docs.openedx.org/projects/openedx-proposals/en/latest/best-practices/oep-0051-bp-conventional-commits.html Co-Authored-By: Claude Opus 4.7 (1M context) --- claude/CLAUDE.md | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/claude/CLAUDE.md b/claude/CLAUDE.md index 5dbe4ac..e328836 100644 --- a/claude/CLAUDE.md +++ b/claude/CLAUDE.md @@ -30,6 +30,46 @@ directory. - Include links to relevant source code, documentation, or version comparisons in commit messages when explaining why changes were made. +## Commit Message Format + +Follow [OEP-51 conventional commits](https://docs.openedx.org/projects/openedx-proposals/en/latest/best-practices/oep-0051-bp-conventional-commits.html) +for all repos, not just openedx. Format: `: \n\n\n\n