← Files NightshiftARCHIVED FILE
runtime/windows/ns.ps1
10 KB · Oct 2, 2026 · 00:30 UTC
<#
.SYNOPSIS
One verb per helper. The runtime picks the file, the flags and the workspace.
.DESCRIPTION
Twin of runtime/ns. Same verbs, same arguments in the same POSIX spelling:
& ns.ps1 <verb> [--flag value ...]
& ns.ps1 bind the six resolved facts
& ns.ps1 help this host's verb table
Skills carry one spelling of every command. This translates it: `--flag value`
becomes `-Flag value`, and a flag naming a switch parameter consumes no value.
Which parameters those are is read off the helper's own param block, so a
helper that grows a flag needs nothing here.
A flag with no matching parameter is passed through untouched, so the helper
refuses in its own words. The dispatcher never invents support a helper does
not have.
Exit: the helper's own status, streams passed through. 1 usage - 2 refused.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true, Position = 0)][string]$Verb,
[Parameter(ValueFromRemainingArguments = $true)][string[]]$Rest = @()
)
Set-StrictMode -Version 2.0
$ErrorActionPreference = 'Stop'
$runtime = Split-Path -Parent (Split-Path -Parent $PSCommandPath)
$pluginRoot = Split-Path -Parent $runtime
Import-Module (Join-Path $pluginRoot 'lib/Nightshift.psm1') -Force -DisableNameChecking
[Console]::OutputEncoding = New-Object Text.UTF8Encoding($false)
# The verbs that change something on disk. Identical in the POSIX dispatcher.
$NSWritingVerbs = @('scaffold', 'archive-receipts', 'stop-shift', 'link-workspace',
'evidence-archive', 'migrate-state', 'apply-profile')
# The host, from the environment the hooks already read. Never from searching.
function Get-NSDispatchHost {
if (-not [string]::IsNullOrEmpty($env:NIGHTSHIFT_HOST)) { return $env:NIGHTSHIFT_HOST }
if (-not [string]::IsNullOrEmpty($env:CLAUDE_PROJECT_DIR + $env:CLAUDECODE + $env:CLAUDE_PLUGIN_ROOT)) { return 'claude' }
if (-not [string]::IsNullOrEmpty($env:CODEX_PROJECT_DIR + $env:CODEX_HOME + $env:CODEX_SANDBOX)) { return 'codex' }
if (-not [string]::IsNullOrEmpty($env:CURSOR_PROJECT_DIR + $env:CURSOR_PLUGIN_ROOT + $env:CURSOR_TRACE_ID)) { return 'cursor' }
return 'unknown'
}
# Every verb this host can run, derived from what is on disk so it cannot drift.
function Get-NSDispatchVerbs {
$names = New-Object 'System.Collections.Generic.List[string]'
foreach ($file in (Get-ChildItem -LiteralPath (Join-Path $runtime 'windows') -Filter '*.ps1' -File -EA SilentlyContinue)) {
if ($file.BaseName -ceq 'ns') { continue }
$names.Add($file.BaseName)
}
return ($names | Sort-Object -Unique)
}
function Get-NSDispatchTarget {
param([Parameter(Mandatory = $true)][string]$Name)
if (($Name -ceq 'ns') -or ($Name -cmatch '[\\/]') -or ($Name -clike '*..*') -or ($Name -clike '-*')) { return '' }
$path = Join-Path (Join-Path $runtime 'windows') ($Name + '.ps1')
if (Test-Path -LiteralPath $path -PathType Leaf) { return $path }
return ''
}
# The helper's own parameters, and which of them are switches. Read from the file rather than
# held in a list here, so the translation follows the helper.
function Get-NSHelperParameters {
param([Parameter(Mandatory = $true)][string]$Path)
$map = @{}
$tokens = $null
$errors = $null
$ast = [Management.Automation.Language.Parser]::ParseFile($Path, [ref]$tokens, [ref]$errors)
$block = $ast.ParamBlock
if ($null -eq $block) { return $map }
foreach ($parameter in $block.Parameters) {
$name = $parameter.Name.VariablePath.UserPath
$isSwitch = $false
if ($null -ne $parameter.StaticType) {
$isSwitch = ($parameter.StaticType.Name -ceq 'SwitchParameter')
}
$map[$name] = $isSwitch
}
return $map
}
# `--allow-closed` is `-AllowClosed`. Where a helper spells it differently, the helper wins: the
# match is by the parameter the helper actually declares, and an unmatched flag is passed through
# untouched so the helper can refuse it in its own words.
function Resolve-NSFlagName {
param(
[Parameter(Mandatory = $true)][string]$Flag,
[Parameter(Mandatory = $true)][hashtable]$Parameters
)
$bare = $Flag.Substring(2)
$pascal = (($bare -split '-') | ForEach-Object {
if ($_.Length -eq 0) { '' } else { $_.Substring(0, 1).ToUpperInvariant() + $_.Substring(1) }
}) -join ''
foreach ($name in $Parameters.Keys) {
if ($name -ceq $pascal) { return $name }
}
foreach ($name in $Parameters.Keys) {
if ($name -ieq $pascal) { return $name }
}
# A helper may name a parameter more fully than the flag does - `--host` is `-HostName`,
# `--input` is `-InputPath`. One unambiguous parameter starting with the flag's own name is
# that parameter; two or more is nothing, and the flag passes through.
$prefixed = @($Parameters.Keys | Where-Object { $_ -ilike ($pascal + '*') })
if ($prefixed.Count -eq 1) { return $prefixed[0] }
return ''
}
$hostName = Get-NSDispatchHost
# The workspace, by the one rule the skills already state. A shell standing inside the state
# folder means the workspace that owns it. An invalid link refuses in the Start preflight's format
# rather than guessing a workspace.
$taskRoot = $env:CLAUDE_PROJECT_DIR
if ([string]::IsNullOrEmpty($taskRoot)) { $taskRoot = $env:CODEX_PROJECT_DIR }
if ([string]::IsNullOrEmpty($taskRoot)) { $taskRoot = $env:CURSOR_PROJECT_DIR }
if ([string]::IsNullOrEmpty($taskRoot)) { $taskRoot = (Get-Location).ProviderPath }
$taskRoot = Get-NSStateDirOwner $taskRoot
# The resolver throws on a link it cannot trust, and a terminating error here would replace the
# refusal with a stack trace.
try { $derived = Resolve-NSWorkspaceRoot $taskRoot } catch { $derived = '' }
if ([string]::IsNullOrEmpty($derived)) {
[Console]::Out.WriteLine('refuse workspace invalid .nightshift-link at ' + $taskRoot)
[Console]::Out.WriteLine('repair Fix or remove .nightshift-link so it holds one absolute path to a folder containing .nightshift/, then run the command again.')
exit 2
}
$workspace = $derived
$source = 'derived'
# A session bound to one workspace and standing in another is not a preference to reconcile: the
# writing verbs would scaffold or file into whichever the dispatcher picked. So the bound value wins
# where it is the only one that resolves, and a disagreement refuses before any verb runs.
function Get-NSCanonicalPath {
param([Parameter(Mandatory = $true)][string]$Path)
try { return (Resolve-Path -LiteralPath $Path -ErrorAction Stop).ProviderPath }
catch { return $Path }
}
if (-not [string]::IsNullOrEmpty($env:NIGHTSHIFT_WORKSPACE)) {
try { $bound = Resolve-NSWorkspaceRoot (Get-NSStateDirOwner $env:NIGHTSHIFT_WORKSPACE) } catch { $bound = '' }
if ([string]::IsNullOrEmpty($bound)) {
[Console]::Out.WriteLine('refuse workspace invalid .nightshift-link at ' + $env:NIGHTSHIFT_WORKSPACE)
[Console]::Out.WriteLine('repair Fix or remove .nightshift-link so it holds one absolute path to a folder containing .nightshift/, then run the command again.')
exit 2
}
$workspace = $bound
$source = 'bound'
if ((Get-NSCanonicalPath $bound) -ne (Get-NSCanonicalPath $derived)) {
[Console]::Out.WriteLine('refuse workspace bound ' + $bound + ' differs from derived ' + $derived)
[Console]::Out.WriteLine('repair cd to the bound workspace, or unset NIGHTSHIFT_WORKSPACE, then run the command again.')
exit 2
}
}
if ($Verb -ceq 'bind') {
[Console]::Out.WriteLine("TASK_ROOT`t" + $taskRoot)
[Console]::Out.WriteLine("NIGHTSHIFT_WORKSPACE`t" + $workspace)
[Console]::Out.WriteLine("NS`t" + (Join-Path $workspace '.nightshift'))
[Console]::Out.WriteLine("NIGHTSHIFT_PLUGIN_ROOT`t" + $pluginRoot)
[Console]::Out.WriteLine("HOST`t" + $hostName)
[Console]::Out.WriteLine("SOURCE`t" + $source)
exit 0
}
if ($Verb -ceq 'help') {
[Console]::Out.WriteLine('ns <verb> [args' + [char]0x2026 + '] ' + [char]0x2014 + ' host ' + $hostName)
[Console]::Out.WriteLine('')
foreach ($name in (Get-NSDispatchVerbs)) {
$target = Get-NSDispatchTarget $name
if ([string]::IsNullOrEmpty($target)) { continue }
[Console]::Out.WriteLine(' ' + $name.PadRight(22) + ' ' + $target)
}
exit 0
}
$target = Get-NSDispatchTarget $Verb
if ([string]::IsNullOrEmpty($target)) {
[Console]::Error.WriteLine('ns: no verb ' + $Verb + ' on ' + $hostName + ' ' + [char]0x2014 + ' run ns help for this host''s verbs')
exit 1
}
# A verb that writes says where before it does. One line, first on stdout, so an owner reading a
# transcript can see which workspace took the change without reconstructing the resolution.
if ($NSWritingVerbs -ccontains $Verb) {
[Console]::Out.WriteLine('workspace ' + $workspace)
}
$parameters = Get-NSHelperParameters $target
# Named parameters are splatted from a hashtable; an array splat would pass `-Project` as a value.
# Positional arguments (a helper's own command word, say) keep their order.
$named = @{}
$positional = New-Object 'System.Collections.Generic.List[string]'
$index = 0
while ($index -lt $Rest.Count) {
$token = $Rest[$index]
if ($token -clike '--*') {
$name = Resolve-NSFlagName -Flag $token -Parameters $parameters
if ([string]::IsNullOrEmpty($name)) {
# Nothing on this helper answers to it. Hand it over as written so the helper refuses
# in its own words rather than having the dispatcher guess.
$positional.Add($token)
$index++
continue
}
if ($parameters[$name]) {
$named[$name] = [switch]$true
}
elseif ($index + 1 -lt $Rest.Count) {
$index++
$named[$name] = $Rest[$index]
}
else {
$named[$name] = ''
}
$index++
continue
}
$positional.Add($token)
$index++
}
# The workspace goes only to helpers that take it, and only when the caller did not say so.
if ((-not $named.ContainsKey('Project')) -and $parameters.ContainsKey('Project')) {
$named['Project'] = $workspace
}
& $target @positional @named
exit $LASTEXITCODE
SHA-256: 41fed67f66e53fe802fe0d8442d0a6297928ad5125fb6a6988fc9eec0e2e912d