Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ jobs:
- uses: actions/checkout@v7
- uses: astral-sh/setup-uv@v10.3.0
- run: uv python install 3.15
# the terminal tests drive bash, zsh and pwsh on a pseudo-terminal; the runner has no zsh
- run: sudo apt-get install -y -qq zsh
- run: uv run --with pytest pytest -q

powershell-help:
Expand Down
27 changes: 27 additions & 0 deletions README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,31 @@ cdg term clear # 기록 모두 지우기
마지막 줄바꿈 포함) 돌아가고, install이 만든 파일은 그때 만든 폴더와 함께 지워집니다. 데이터 폴더의
훅 사본도 지워집니다. 기록된 명령은 `cdg term clear`로 지울 때까지 남습니다.

##### 다른 명령을 실행하는 프로그램

[hanfix](https://github.com/NaNIsaac/hanfix)의 `hf`(한글 자판으로 친 직전 명령을 고쳐 다시 실행)처럼
명령 줄을 대신 실행하는 프로그램은, 그대로 두면 통째로 기록되고 그 안에서 실행한 명령이 터미널 대신
기록용 파이프를 받게 됩니다. 이런 프로그램은 두 단계로 훅과 함께 쓸 수 있습니다.

1. 불러올 때 자기 명령 이름을 `CODIGEST_RUNNERS`에 더합니다. 이 이름으로 시작하는 줄은 기록하지
않습니다. 덮어쓰지 말고 더해야 시작 파일에서 codigest와 어느 쪽을 먼저 불러와도 됩니다.
- PowerShell: `$global:CODIGEST_RUNNERS += 'hf'`
- bash/zsh: `CODIGEST_RUNNERS="${CODIGEST_RUNNERS-} hf"` (공백으로 나눈 셸 변수)
2. 명령 줄을 실행할 때 `__cdg_run`이 정의되어 있으면 그것으로 실행합니다(실행하는 그때 찾습니다).
그 줄에 훅의 규칙을 그대로 적용하므로, 터미널이 필요한 프로그램은 터미널을 받고 나머지는 그 줄로
기록됩니다.
- PowerShell: `if (Get-Command __cdg_run -CommandType Function -ErrorAction Ignore) { . __cdg_run $line } else { Invoke-Expression $line }`
- bash/zsh: `if typeset -f __cdg_run >/dev/null; then __cdg_run "$line"; else eval "$line"; fi`

2단계를 하지 않는 프로그램도 `CODIGEST_RUNNERS`에 넣을 수 있습니다. 그러면 그 프로그램이 실행하는
명령은 터미널을 받고 기록되지 않습니다. [thefuck](https://github.com/nvbn/thefuck)이 그런 예입니다:
`$global:CODIGEST_RUNNERS += 'fuck'` (bash/zsh: `CODIGEST_RUNNERS="${CODIGEST_RUNNERS-} fuck"`).

PowerShell에서는 다른 `PSConsoleHostReadLine`(VS Code의 셸 통합, atuin)과 함께 동작합니다. 줄을 읽을
때 그것을 불러 입력한 그대로의 줄을 보게 하고, 돌려받은 줄만 감쌉니다. 그런 프로그램이 훅보다 나중에
불러와지면, 그것이 줄을 직접 읽더라도(atuin처럼) 훅은 셸이 입력을 기다리는 동안 다시 앞에 섭니다.
그 직후의 첫 줄은 기록되지 않습니다.

프로필은 패키지 안의 파일이 아니라, `cdg term install`이 데이터 폴더(기록 옆의 `shell/`)에 둔 훅 사본을 불러옵니다. codigest를 업데이트한 뒤에는 `cdg term install`을 다시 실행해 사본을 갱신하세요.

알아 둘 점:
Expand All @@ -471,6 +496,8 @@ cdg term clear # 기록 모두 지우기
- PowerShell 훅은 원격 세션, 중첩 프롬프트, 디버거에서는 줄을 감싸지 않습니다.
- bash 훅은 DEBUG 트랩을 씁니다. 첫 프롬프트 전에 설정된 트랩(starship, bash-preexec)은 그대로
두지만, 그 뒤에 새로 설정한 DEBUG 트랩은 훅을 덮어씁니다.
- bash에서 VS Code의 셸 통합을 훅보다 나중에 불러오면 기록되지 않습니다
([#52](https://github.com/NaNIsaac/codigest/issues/52)).

### MCP 도구

Expand Down
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -485,6 +485,34 @@ normally) and appear in your history as typed. Programs that need a real termina
any folder made for it, as are the hook copies in the data folder. Captured commands stay
until `cdg term clear`.

##### Programs that run other commands

A program that runs a command line for you, such as `hf` from
[hanfix](https://github.com/NaNIsaac/hanfix) (it re-runs the previous command typed with the Korean
layout on), would otherwise be captured as a whole, and the command it runs would get the capture
pipe instead of the terminal. Such a program can work with the hook in two steps:

1. Add its command names to `CODIGEST_RUNNERS` when it loads; the hook does not capture a line that
starts with one of them. Append, so the order in which the startup file loads it and codigest
does not matter:
- PowerShell: `$global:CODIGEST_RUNNERS += 'hf'`
- bash/zsh: `CODIGEST_RUNNERS="${CODIGEST_RUNNERS-} hf"` (a shell variable, space-separated)
2. Run each command line through `__cdg_run` when it is defined, looked up when the line runs. It
applies the hook's own rules to that line: a program that needs the terminal keeps it, anything
else is captured under that line.
- PowerShell: `if (Get-Command __cdg_run -CommandType Function -ErrorAction Ignore) { . __cdg_run $line } else { Invoke-Expression $line }`
- bash/zsh: `if typeset -f __cdg_run >/dev/null; then __cdg_run "$line"; else eval "$line"; fi`

A program that does not do step 2 can still be listed in `CODIGEST_RUNNERS`: what it runs then keeps
the terminal and is not captured. [thefuck](https://github.com/nvbn/thefuck) is one:
`$global:CODIGEST_RUNNERS += 'fuck'` (bash/zsh: `CODIGEST_RUNNERS="${CODIGEST_RUNNERS-} fuck"`).

In PowerShell the hook works with another `PSConsoleHostReadLine` (VS Code's shell integration,
atuin): it calls that one for the line, so it sees the line as typed, and only wraps what it
returns. When one is loaded after the hook, the hook puts itself back on top while the shell
waits for input, also when that one reads the line itself (atuin does); the first line after it
is not captured.

The profile loads a copy of the hook that `cdg term install` puts in the data folder (`shell/` next to the captures), not the file inside the package. After updating codigest, run `cdg term install` again to refresh it.

Limitations:
Expand All @@ -495,6 +523,8 @@ Limitations:
normally. In bash, neither are lines that start with a subshell `( ... )`.
- In bash the hook uses the DEBUG trap and keeps one set before the first prompt (starship,
bash-preexec); a DEBUG trap set later replaces it.
- In bash, VS Code's shell integration loaded after the hook stops the capture
([#52](https://github.com/NaNIsaac/codigest/issues/52)).
- Long output is bounded: the PowerShell hook keeps the first and last 2,000 lines, and a bash or
zsh capture file over 4 MB is read 1 MB from each end. The store then keeps 64 KB.
- In PowerShell, a command counts as failed when its exit code is non-zero or an error reached
Expand Down
87 changes: 76 additions & 11 deletions src/codigest/shell/hook.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ function global:__cdg {
return
}
# Dot-sourced: these locals live in the caller's scope, hence the prefix and cleanup.
$__cdgLine = $global:__cdgLine
$__cdgCommand = $global:__cdgLine
# Left as is so the line can read it; a native command stores a new boxed int,
# so a reference change means one ran, even with the same exit code.
$__cdgPrevExit = $global:LASTEXITCODE
Expand Down Expand Up @@ -76,7 +76,7 @@ function global:__cdg {
if ($global:__cdgTail.Count) { $__cdgText += $global:__cdgTail | Out-String }
try {
$__cdgJson = @{
command = $__cdgLine
command = $__cdgCommand
output = $__cdgText
exit_code = $__cdgExit
cwd = $PWD.ProviderPath
Expand Down Expand Up @@ -105,7 +105,7 @@ function global:__cdg {
[Exception]::new("exit $__cdgExit"), 'CodigestExit', 'NotSpecified', $null))
$ErrorActionPreference = $__cdgPref
}
Remove-Variable __cdgPref, __cdgBlock, __cdgLine, __cdgPrevExit, __cdgUnbuf, __cdgStart, __cdgText, __cdgExit, __cdgJson, __cdgBytes, __cdgPsi, __cdgProc -ErrorAction Ignore
Remove-Variable __cdgPref, __cdgBlock, __cdgCommand, __cdgPrevExit, __cdgUnbuf, __cdgStart, __cdgText, __cdgExit, __cdgJson, __cdgBytes, __cdgPsi, __cdgProc -ErrorAction Ignore
}
}

Expand Down Expand Up @@ -148,22 +148,70 @@ function global:__cdgRestoreHistory {
} | Add-History
}

# Replaces PSReadLine's own PSConsoleHostReadLine, which the host calls for every prompt.
# PSReadLine draws and stores the line as typed; only the string returned to the engine
# is wrapped, so nothing changes on screen.
# Whether a line is captured. A program that runs other command lines (hanfix's hf) lists
# its names in $global:CODIGEST_RUNNERS: its own line is not captured, so it keeps the
# terminal, and it hands each line it runs to __cdg_run, which decides for that line.
function global:__cdgWants([string]$line) {
$line.Trim() -and $line -notmatch '^\s' -and $line -notmatch $global:__cdgSkip -and
-not ($env:CODIGEST_CAP_SKIP -and $line -match $env:CODIGEST_CAP_SKIP) -and -not $env:CODIGEST_NOCAP -and
($line.Trim() -split '\s+', 2)[0] -notin @($global:CODIGEST_RUNNERS)
}

# Runs a command line for a runner, captured as if it had been typed. Dot-source it
# (`. __cdg_run $line`) so the line runs in the runner's scope, as Invoke-Expression would.
function global:__cdg_run([string]$__cdgRunLine) {
$__cdgRunBlock = [scriptblock]::Create($__cdgRunLine)
if ($null -eq $global:__cdgHead -and (__cdgWants $__cdgRunLine)) {
$global:__cdgLine = $__cdgRunLine
. __cdg $__cdgRunBlock
}
else { . $__cdgRunBlock }
}

function global:__cdgReadLine([bool]$ok) {
try { [Microsoft.PowerShell.PSConsoleReadLine]::ReadLine($host.Runspace, $ExecutionContext, $ok) }
catch [Management.Automation.MethodException] { # PSReadLine < 2.2 has no status argument
[Microsoft.PowerShell.PSConsoleReadLine]::ReadLine($host.Runspace, $ExecutionContext) }
}

# Another PSConsoleHostReadLine (atuin, a terminal's shell integration) is called for the line
# and gets it as typed; codigest wraps only what it returns. PSReadLine's own is not one: it
# would lose the last status. Loading the hook again keeps the one found the first time.
$__cdgTop = Get-Item function:PSConsoleHostReadLine -ErrorAction Ignore
if ($__cdgTop -and $__cdgTop.Module.Name -ne 'PSReadLine' -and $__cdgTop.Definition -notmatch 'codigest-readline') {
$global:__cdgPrev = $__cdgTop.ScriptBlock
}
Remove-Variable __cdgTop

# Replaces PSConsoleHostReadLine, which the host calls for every prompt. PSReadLine draws and
# stores the line as typed; only the string returned to the engine is wrapped, so nothing
# changes on screen.
function global:PSConsoleHostReadLine {
# codigest-readline (marks this function as codigest's)
# First statement: any script run resets $?, and PSReadLine shows it in the prompt.
$__cdgOk = $?
Microsoft.PowerShell.Core\Set-StrictMode -Off
# Called back by the function this one calls: read the line and nothing more.
if ($global:__cdgReading) { return __cdgReadLine $global:__cdgOk }
__cdgRestoreHistory
$line = try { [Microsoft.PowerShell.PSConsoleReadLine]::ReadLine($host.Runspace, $ExecutionContext, $__cdgOk) }
catch [Management.Automation.MethodException] { # PSReadLine < 2.2 has no status argument
[Microsoft.PowerShell.PSConsoleReadLine]::ReadLine($host.Runspace, $ExecutionContext) }
# A program that loaded later (VS Code's shell integration) wrapped this function and calls
# it for the line. Put this one back on top, around that one, for the next line; this line
# goes to it as typed and is not captured.
if ((Get-Item function:PSConsoleHostReadLine).Definition -notmatch 'codigest-readline') {
$global:__cdgPrev = (Get-Item function:PSConsoleHostReadLine).ScriptBlock
Set-Item function:global:PSConsoleHostReadLine $global:__cdgSelf
return __cdgReadLine $__cdgOk
}
$line = if ($global:__cdgPrev) {
$global:__cdgOk = $__cdgOk
$global:__cdgReading = $true
try { & $global:__cdgPrev } finally { $global:__cdgReading = $false }
}
else { __cdgReadLine $__cdgOk }
# Not in a remote session, a nested prompt or the debugger: there the line is for
# another runspace or is a debugger command (c, s, v), and must reach it as typed.
$local = -not $host.Runspace.RunspaceIsRemote -and -not $NestedPromptLevel -and -not $PSDebugContext
$wrap = $local -and $line.Trim() -and $line -notmatch '^\s' -and $line -notmatch $global:__cdgSkip -and
-not ($env:CODIGEST_CAP_SKIP -and $line -match $env:CODIGEST_CAP_SKIP) -and -not $env:CODIGEST_NOCAP
$wrap = $local -and (__cdgWants $line)
if ($wrap) {
$tokens = $null; $errs = $null
# Single-line form first; the newline form survives a trailing `# comment`.
Expand All @@ -178,6 +226,23 @@ function global:PSConsoleHostReadLine {
}
$line
}
$global:__cdgSelf = (Get-Item function:PSConsoleHostReadLine).ScriptBlock

# A program that loads later and never calls the function it replaced (atuin reads the line
# itself) would stop the capture for good. While PSReadLine waits for a line, put this one back
# on top around that one; from the next prompt on, codigest is called again. -SupportEvent
# keeps the subscription out of Get-Job, and the flag keeps a reload from adding a second one.
if (-not $global:__cdgIdle) {
$global:__cdgIdle = $true
Register-EngineEvent -SourceIdentifier PowerShell.OnIdle -SupportEvent -Action {
$top = Get-Item function:PSConsoleHostReadLine -ErrorAction Ignore
if ($top -and $top.Module.Name -ne 'PSReadLine' -and $top.Definition -notmatch 'codigest-readline') {
__cdgRestoreHistory
$global:__cdgPrev = $top.ScriptBlock
Set-Item function:global:PSConsoleHostReadLine $global:__cdgSelf
}
}
}

# Lets `cdg term` tell whether this shell is hooked.
$env:CODIGEST_HOOK = '1'
16 changes: 16 additions & 0 deletions src/codigest/shell/hook.sh
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@ __cdg_wants() {
[[ $1 =~ $__cdg_skip ]] && return 1
[[ -n ${CODIGEST_CAP_SKIP-} && $1 =~ $CODIGEST_CAP_SKIP ]] && return 1
[[ $1 =~ $__cdg_commit && ! $1 =~ $__cdg_msg ]] && return 1
# A program that runs other command lines (hanfix's hf) lists its names in
# CODIGEST_RUNNERS: its own line is not captured, so it keeps the terminal, and it
# hands each line it runs to __cdg_run, which decides for that line.
local first=${1%%[[:space:]]*}
[[ " ${CODIGEST_RUNNERS-} " == *" $first "* ]] && return 1
# The recorded line would show `!!`-style history expansion unexpanded.
if [ "$__cdg_shell" = zsh ]; then [[ -o banghist && $1 == *!* ]] && return 1
else [[ $- == *H* && $1 == *!* ]] && return 1; fi
Expand Down Expand Up @@ -127,6 +132,17 @@ __cdg_post() {
return $s
}

# Runs a command line for a runner, captured as if it had been typed. The DEBUG trap and
# preexec do not run for the commands inside a function, so the capture starts here; the
# next prompt ends it as usual.
__cdg_run() {
if [ -z "$__cdg_file" ] && __cdg_wants "$1"; then
__cdg_open "$1"
__cdg_start
fi
eval "$1"
}

__cdg_file= __cdg_w= __cdg_busy=
if [ "$__cdg_shell" = zsh ]; then
zmodload zsh/zselect 2>/dev/null
Expand Down
Loading
Loading