inscope 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +81 -0
- package/dist/bin/index.mjs +139 -0
- package/dist/index.d.mts +107 -0
- package/dist/index.mjs +56 -0
- package/package.json +74 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Neeraj Dalal
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# inscope
|
|
2
|
+
|
|
3
|
+
Per-workspace identity for [Claude Code](https://claude.com/claude-code). Scope
|
|
4
|
+
MCP servers, GitHub auth, and git commit identity to the directory you are in,
|
|
5
|
+
so concurrent sessions in different projects never bleed work and personal
|
|
6
|
+
accounts into each other.
|
|
7
|
+
|
|
8
|
+
You describe each workspace once; `inscope` owns the moving parts and keeps them
|
|
9
|
+
in sync:
|
|
10
|
+
|
|
11
|
+
- a `.mcp.json` at each workspace root, with uniquely named servers
|
|
12
|
+
- a single zsh `chpwd` hook that resolves the right tokens from `$PWD`
|
|
13
|
+
- git `includeIf` rules so commits get the right email per path
|
|
14
|
+
|
|
15
|
+
Nothing sensitive is written to disk. GitHub tokens come from the `gh` keyring
|
|
16
|
+
and Slack tokens from the macOS Keychain, resolved live by the hook.
|
|
17
|
+
|
|
18
|
+
> Background and the why behind the design:
|
|
19
|
+
> [Race-Free Identity in Claude Code](https://zerostarter.dev/blog/mcp-per-workspace).
|
|
20
|
+
|
|
21
|
+
## Requirements
|
|
22
|
+
|
|
23
|
+
macOS, zsh, [`gh`](https://cli.github.com), and Claude Code.
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npm i -g inscope
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Quickstart
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
# 1. set up the config + hook, and source it from ~/.zshrc
|
|
35
|
+
inscope init
|
|
36
|
+
|
|
37
|
+
# 2. sign each GitHub account into gh (once)
|
|
38
|
+
gh auth login # repeat per account
|
|
39
|
+
|
|
40
|
+
# 3. map your workspaces
|
|
41
|
+
inscope add ~/acme --gh acme --email you@acme.com --servers github,linear,notion,slack
|
|
42
|
+
inscope add ~/nrjdalal --gh nrjdalal --email you@personal.dev
|
|
43
|
+
|
|
44
|
+
# 4. reload your shell, then verify
|
|
45
|
+
source ~/.zshrc
|
|
46
|
+
inscope doctor
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
`cd ~/acme/api` and you are the work account, with work MCP servers and your
|
|
50
|
+
work commit email. `cd ~/nrjdalal/blog` and you are you. No toggles, and it
|
|
51
|
+
holds up with several terminals open at once.
|
|
52
|
+
|
|
53
|
+
## Commands
|
|
54
|
+
|
|
55
|
+
| Command | What it does |
|
|
56
|
+
| -------------- | -------------------------------------------------------------------- |
|
|
57
|
+
| `inscope init` | create the config, generate the hook, source it from `~/.zshrc` |
|
|
58
|
+
| `inscope add` | map a directory to a gh account, git email, and MCP servers |
|
|
59
|
+
| `inscope rm` | remove a workspace mapping |
|
|
60
|
+
| `inscope list` | list configured workspaces |
|
|
61
|
+
| `inscope apply` | regenerate the hook, git includes, and every `.mcp.json` from config |
|
|
62
|
+
| `inscope doctor` | verify tokens, identities, and the hook resolve correctly |
|
|
63
|
+
|
|
64
|
+
Run any command with `-h` for its options.
|
|
65
|
+
|
|
66
|
+
## What it manages
|
|
67
|
+
|
|
68
|
+
| Surface | Location |
|
|
69
|
+
| ------------ | ----------------------------------------------------------- |
|
|
70
|
+
| Config | `~/.config/claude/workspaces.json` |
|
|
71
|
+
| chpwd hook | `~/.config/claude/mcp-tokens.zsh` |
|
|
72
|
+
| MCP servers | `<workspace>/.mcp.json` |
|
|
73
|
+
| Git identity | `~/.gitconfig` includeIf + `~/.config/git/<name>.gitconfig` |
|
|
74
|
+
|
|
75
|
+
Edit `workspaces.json` by hand if you like, then run `inscope apply`. It only
|
|
76
|
+
touches the blocks it owns; your other `.zshrc`, `.gitconfig`, and `.mcp.json`
|
|
77
|
+
content is left alone.
|
|
78
|
+
|
|
79
|
+
## License
|
|
80
|
+
|
|
81
|
+
MIT
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import{parseArgs as e}from"node:util";import t from"node:fs";import n from"node:path";import r from"node:os";import{spawnSync as i}from"node:child_process";import a from"node:readline";const o=()=>r.homedir(),s=()=>process.env.XDG_CONFIG_HOME?.trim()||n.join(o(),`.config`),c=e=>e===`~`?o():e.startsWith(`~/`)?n.join(o(),e.slice(2)):e,l=e=>{let t=c(e),r=o();return t===r?`~`:t.startsWith(r+n.sep)?`~/`+t.slice(r.length+1):t},u=e=>n.resolve(c(e)),d=()=>n.join(s(),`claude`,`workspaces.json`),f=()=>n.join(s(),`claude`,`mcp-tokens.zsh`),p=()=>n.join(s(),`git`),m=()=>n.join(o(),`.gitconfig`),h=()=>n.join(o(),`.zshrc`),g=e=>`# >>> inscope:${e} >>>`,_=e=>`# <<< inscope:${e} <<<`,v=e=>e.replace(/[.*+?^${}()|[\]\\]/g,`\\$&`),y=e=>RegExp(`${v(g(e))}\\n[\\s\\S]*?\\n${v(_(e))}\\n?`),b=(e,t)=>{let n=t.replace(/\n+$/,``);return`${g(e)}\n${n}\n${_(e)}\n`},x=e=>{try{return t.readFileSync(e,`utf8`)}catch{return``}},S=(e,r,i)=>{t.mkdirSync(n.dirname(e),{recursive:!0});let a=x(e),o=b(r,i),s=y(r),c;if(s.test(a))c=a.replace(s,o);else{let e=a.replace(/\n*$/,``);c=e.length?`${e}\n\n${o}`:o}t.writeFileSync(e,c)},ee=(e,n)=>{let r=x(e);if(!r)return;let i=r.replace(y(n),``).replace(/\n{3,}/g,`
|
|
3
|
+
|
|
4
|
+
`).replace(/^\n+/,``);t.writeFileSync(e,i)},C=(e,t)=>{let n=x(e).match(RegExp(`${v(g(t))}\\n([\\s\\S]*?)\\n${v(_(t))}`));return n?n[1]:null},w=`gitconfig`,T=e=>!!(e.git&&(e.git.email||e.git.name)),E=e=>n.join(p(),`${e}.gitconfig`),te=e=>l(e).replace(/\/+$/,``)+`/`,ne=e=>e.workspaces.filter(T).map(e=>`[includeIf "gitdir:${te(e.path)}"]\n\tpath = ${l(E(e.name))}`).join(`
|
|
5
|
+
`),re=e=>{let t=[`# Managed by inscope. Do not edit by hand.`,`[user]`];return e.git?.email&&t.push(`\temail = ${e.git.email}`),e.git?.name&&t.push(`\tname = ${e.git.name}`),t.join(`
|
|
6
|
+
`)+`
|
|
7
|
+
`},ie=e=>{t.mkdirSync(p(),{recursive:!0});for(let n of e.workspaces)T(n)&&t.writeFileSync(E(n.name),re(n));let n=ne(e);n?S(m(),w,n):ee(m(),w)},ae=e=>{let n=E(e);t.existsSync(n)&&t.rmSync(n)},D=e=>{let t=l(e);return t===`~`?`"$HOME/"*`:t.startsWith(`~/`)?`"$HOME/${t.slice(2)}/"*`:`"${t}/"*`},O=e=>e.servers.slack?e.servers.slack.keychain:``,k=e=>{let t=[...e.workspaces].sort((e,t)=>e.name.localeCompare(t.name));return`# Managed by inscope. Do not edit by hand.
|
|
8
|
+
# Source of truth: ~/.config/claude/workspaces.json
|
|
9
|
+
# Edit there, then run \`inscope apply\` to regenerate this file.
|
|
10
|
+
#
|
|
11
|
+
# One chpwd hook resolves per-workspace secrets from \$PWD on every cd: it maps
|
|
12
|
+
# the current directory to a workspace, then pulls that workspace's GitHub token
|
|
13
|
+
# from the gh keyring and Slack token from the macOS keychain. Nothing sensitive
|
|
14
|
+
# is written to disk, and there is no shared mutable state for sessions to race.
|
|
15
|
+
__inscope_resolve_identity() {
|
|
16
|
+
local ws
|
|
17
|
+
case "\${PWD}/" in
|
|
18
|
+
${t.map(e=>` ${D(e.path)}) ws=${e.name} ;;`).join(`
|
|
19
|
+
`)||` # no workspaces configured`}
|
|
20
|
+
*) ws="" ;;
|
|
21
|
+
esac
|
|
22
|
+
[[ "$ws" == "$__inscope_ws" ]] && return # workspace unchanged, skip the lookups
|
|
23
|
+
__inscope_ws="$ws"
|
|
24
|
+
unset GITHUB_TOKEN GH_TOKEN SLACK_MCP_XOXP_TOKEN # clear previous (and any inherited) tokens
|
|
25
|
+
|
|
26
|
+
local gh_user="" slack_svc=""
|
|
27
|
+
case "$ws" in
|
|
28
|
+
${t.map(e=>{let t=[];e.gh&&t.push(`gh_user=${e.gh}`);let n=O(e);return n&&t.push(`slack_svc=${n}`),` ${e.name}) ${t.length?t.join(`; `):`:`} ;;`}).join(`
|
|
29
|
+
`)||` # no workspaces configured`}
|
|
30
|
+
*) return ;; # outside a mapped workspace: nothing set
|
|
31
|
+
esac
|
|
32
|
+
|
|
33
|
+
local tok
|
|
34
|
+
if [[ -n "$gh_user" ]]; then
|
|
35
|
+
if tok="$(gh auth token -u "$gh_user" 2>/dev/null)" && [[ -n "$tok" ]]; then
|
|
36
|
+
export GITHUB_TOKEN="$tok" GH_TOKEN="$tok"
|
|
37
|
+
else
|
|
38
|
+
print -u2 "inscope: no gh token for $gh_user; GITHUB_TOKEN/GH_TOKEN unset"
|
|
39
|
+
fi
|
|
40
|
+
fi
|
|
41
|
+
if [[ -n "$slack_svc" ]]; then
|
|
42
|
+
if tok="$(security find-generic-password -a "$USER" -s "$slack_svc" -w 2>/dev/null)" && [[ -n "$tok" ]]; then
|
|
43
|
+
export SLACK_MCP_XOXP_TOKEN="$tok"
|
|
44
|
+
else
|
|
45
|
+
print -u2 "inscope: $slack_svc not in keychain; SLACK_MCP_XOXP_TOKEN unset"
|
|
46
|
+
fi
|
|
47
|
+
fi
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
autoload -Uz add-zsh-hook
|
|
51
|
+
add-zsh-hook chpwd __inscope_resolve_identity
|
|
52
|
+
__inscope_ws="__init__" # force the first resolve, clearing any inherited token
|
|
53
|
+
__inscope_resolve_identity
|
|
54
|
+
`},A=[`github`,`linear`,`notion`,`slack`],j=e=>A.map(t=>`${t}-${e}`),M=e=>n.join(u(e.path),`.mcp.json`),N=(e,t)=>e&&typeof e==`object`&&e.url?e.url:t,oe=e=>{let t=e.servers,n={};if(t.github&&(n[`github-${e.name}`]={type:`http`,url:`https://api.githubcopilot.com/mcp/`,headers:{Authorization:"Bearer ${GITHUB_TOKEN}"}}),t.linear&&(n[`linear-${e.name}`]={type:`http`,url:N(t.linear,`https://mcp.linear.app/mcp`)}),t.notion&&(n[`notion-${e.name}`]={type:`http`,url:N(t.notion,`https://mcp.notion.com/mcp`)}),t.slack){let r={SLACK_MCP_XOXP_TOKEN:"${SLACK_MCP_XOXP_TOKEN}"};t.slack.addMessageTool&&(r.SLACK_MCP_ADD_MESSAGE_TOOL=`true`),n[`slack-${e.name}`]={type:`stdio`,command:`npx`,args:[`-y`,`slack-mcp-server@1.3.0`,`--transport`,`stdio`],env:r}}return n},P=e=>{if(!t.existsSync(e))return{};try{return JSON.parse(t.readFileSync(e,`utf8`))}catch{return{}}},se=e=>{let n=M(e);return t.existsSync(n)?P(n):null},ce=e=>{let r=M(e);t.mkdirSync(n.dirname(r),{recursive:!0});let i=P(r),a=i.mcpServers&&typeof i.mcpServers==`object`?{...i.mcpServers}:{};for(let t of j(e.name))delete a[t];Object.assign(a,oe(e)),i.mcpServers=a,t.writeFileSync(r,JSON.stringify(i,null,2)+`
|
|
55
|
+
`)},le=e=>{let n=M(e);if(!t.existsSync(n))return;let r=P(n);if(r.mcpServers&&typeof r.mcpServers==`object`)for(let t of j(e.name))delete r.mcpServers[t];t.writeFileSync(n,JSON.stringify(r,null,2)+`
|
|
56
|
+
`)},F=`zshrc`,ue=e=>{let t=o();return e===t?`$HOME`:e.startsWith(t+n.sep)?`$HOME/${e.slice(t.length+1)}`:e},de=()=>{let e=ue(f()),t=`# Loads each workspace's tokens (GitHub, Slack) from $PWD on every cd.\n[ -r "${e}" ] && source "${e}"`;S(h(),F,t)},I=e=>{let r=f();t.mkdirSync(n.dirname(r),{recursive:!0}),t.writeFileSync(r,k(e)),ie(e),de();let i=[];for(let t of e.workspaces)ce(t),i.push(M(t));return{hook:r,gitconfig:e.workspaces.some(e=>e.git?.email||e.git?.name),mcp:i}},L=()=>({version:1,workspaces:[]}),R=()=>t.existsSync(d()),z=()=>{let e=d(),n=t.readFileSync(e,`utf8`),r=JSON.parse(n);return V(r),r},B=e=>{let r=d();t.mkdirSync(n.dirname(r),{recursive:!0}),t.writeFileSync(r,JSON.stringify(e,null,2)+`
|
|
57
|
+
`)},V=e=>{if(!e||typeof e!=`object`)throw Error(`config is not an object`);if(!Array.isArray(e.workspaces))throw Error(`config.workspaces must be an array`);let t=new Set;for(let n of e.workspaces){if(!n.name)throw Error(`a workspace is missing a name`);if(!n.path)throw Error(`workspace "${n.name}" is missing a path`);if(t.has(n.name))throw Error(`duplicate workspace name "${n.name}"`);t.add(n.name)}},H=e=>n.basename(u(e)),fe=(e,t)=>{let n=e.workspaces.find(e=>e.name===t);if(n)return n;let r=u(t);return e.workspaces.find(e=>u(e.path)===r)},pe=(e,t)=>{let n=e.workspaces.filter(e=>e.name!==t.name);return n.push({...t,path:l(t.path)}),n.sort((e,t)=>e.name.localeCompare(t.name)),{...e,workspaces:n}},me=(e,t)=>{let n=fe(e,t);return n?{cfg:{...e,workspaces:e.workspaces.filter(e=>e.name!==n.name)},removed:n}:{cfg:e}},U=(e,t,n)=>{let r=i(e,t,{encoding:`utf8`,input:n?.input});return{status:r.status??(r.error?127:1),stdout:r.stdout??``,stderr:r.stderr??``}},he=()=>process.platform===`darwin`,W=()=>process.env.USER||``,ge=(e,t=U)=>{let n=t(`gh`,[`auth`,`token`,`-u`,e]),r=n.stdout.trim();return n.status===0&&r?r:null},G=(e,t=U)=>{let n=t(`security`,[`find-generic-password`,`-a`,W(),`-s`,e,`-w`]);return n.status===0&&n.stdout.trim().length>0},_e=(e,t,n=U)=>{let r=n(`security`,[`add-generic-password`,`-U`,`-a`,W(),`-s`,e,`-w`,t]);if(r.status!==0)throw Error(`security add-generic-password failed: ${r.stderr.trim()||`unknown error`}`)},K=e=>`security add-generic-password -U -a "${W()||`$USER`}" -s ${e} -w 'xoxp-...'`,ve=(e,t=U)=>{let n=t(`git`,[`config`,`--file`,e,`user.email`]);return n.status===0?n.stdout.trim():null},ye=e=>new Promise(t=>{let n=a.createInterface({input:process.stdin,output:process.stdout}),r=n.output,i=!1;n._writeToOutput=t=>{if(!i){r.write(t),t.includes(e)&&(i=!0);return}},n.question(e,e=>{r.write(`
|
|
58
|
+
`),n.close(),t(e.trim())})});var q=`inscope`,J=`0.1.0`,Y={name:`Neeraj Dalal`,email:`admin@nrjdalal.com`,url:`https://nrjdalal.com`};const X=`Map a directory to a GitHub account, git email, and MCP servers.
|
|
59
|
+
Re-running with the same path or label updates that workspace.
|
|
60
|
+
|
|
61
|
+
Usage:
|
|
62
|
+
$ ${q} add <path> [options]
|
|
63
|
+
|
|
64
|
+
Options:
|
|
65
|
+
--gh <account> gh account whose token this workspace uses
|
|
66
|
+
--email <email> git commit email for this workspace
|
|
67
|
+
--git-name <name> git commit author name (optional)
|
|
68
|
+
--label <name> workspace name; defaults to the directory basename
|
|
69
|
+
--servers <list> comma-separated: github,linear,notion,slack
|
|
70
|
+
(default: github,linear,notion)
|
|
71
|
+
--slack-keychain <s> keychain service for the Slack token
|
|
72
|
+
(default: slack-<label>-mcp-xoxp when slack is on)
|
|
73
|
+
--slack-message allow the Slack MCP server to post messages
|
|
74
|
+
--seed-slack prompt for the Slack token and store it in the keychain
|
|
75
|
+
-h, --help Display help message`,be=async t=>{let{positionals:n,values:r}=e({allowPositionals:!0,options:{help:{type:`boolean`,short:`h`},gh:{type:`string`},email:{type:`string`},"git-name":{type:`string`},label:{type:`string`},servers:{type:`string`},"slack-keychain":{type:`string`},"slack-message":{type:`boolean`},"seed-slack":{type:`boolean`}},args:t});r.help&&(console.log(X),process.exit(0));let i=n[0];if(!i)throw Error(X);let a=r.label||H(i),o=(r.servers??`github,linear,notion`).split(`,`).map(e=>e.trim()).filter(Boolean),s=o.includes(`slack`)||!!r[`slack-keychain`]||!!r[`seed-slack`],c=r[`slack-keychain`]||`slack-${a}-mcp-xoxp`,u={github:o.includes(`github`),linear:o.includes(`linear`),notion:o.includes(`notion`),slack:s?{keychain:c,addMessageTool:!!r[`slack-message`]}:!1},d=r.email||r[`git-name`]?{email:r.email,name:r[`git-name`]}:void 0,f={name:a,path:l(i),gh:r.gh,git:d,servers:u},p=pe(R()?z():L(),f);if(B(p),I(p),console.log(`✓ workspace "${a}" -> ${f.path}`),console.log(`✓ regenerated the hook, git includes, and ${f.path}/.mcp.json`),u.slack)if(r[`seed-slack`]){let e=await ye(`Paste the Slack xoxp token for ${c}: `);e?(_e(c,e),console.log(`✓ stored ${c} in the macOS keychain`)):console.error(`No token entered; skipped keychain write.`)}else G(c)||console.log(`\nSlack token not in the keychain yet. Store it once with:\n ${K(c)}`);console.log(`\nLaunch \`claude\` from ${f.path} (or relaunch) to pick up the new identity.`),process.exit(0)},xe=`Regenerate the chpwd hook, git includes, and every .mcp.json
|
|
76
|
+
from your config. Idempotent: run it any time the config changes.
|
|
77
|
+
|
|
78
|
+
Usage:
|
|
79
|
+
$ ${q} apply
|
|
80
|
+
|
|
81
|
+
Options:
|
|
82
|
+
-h, --help Display help message`,Se=t=>{let{values:n}=e({allowPositionals:!0,options:{help:{type:`boolean`,short:`h`}},args:t});n.help&&(console.log(xe),process.exit(0)),R()||(console.error(`No config found. Run \`${q} init\` first.`),process.exit(1));let r=z(),i=I(r);console.log(`✓ hook ${i.hook}`),i.gitconfig&&console.log(`✓ gitconfig ~/.gitconfig (includeIf block)`);for(let e of i.mcp)console.log(`✓ mcp ${e}`);console.log(`\nApplied ${r.workspaces.length} workspace(s).`),process.exit(0)},Ce=e=>{try{return t.readFileSync(e,`utf8`)}catch{return null}},we=e=>{let t=[],n=e?.mcpServers;if(!n||typeof n!=`object`)return t;for(let[e,r]of Object.entries(n)){let n=Array.isArray(r?.args)?r.args:[];if(n.some(e=>typeof e==`string`&&/@latest$/.test(e)))t.push(e);else if(r?.command===`npx`){let r=n.find(e=>typeof e==`string`&&!e.startsWith(`-`));r&&!r.includes(`@`)&&t.push(e)}}return t},Te=(e,t=process.cwd())=>{let r=n.resolve(t);return e.workspaces.find(e=>{let t=u(e.path);return r===t||r.startsWith(t+n.sep)})},Ee=(e=U)=>{let t=e(`gh`,[`api`,`user`,`--jq`,`.login`]),n=e(`git`,[`config`,`user.email`]);return{pwd:process.cwd(),gh:t.status===0&&t.stdout.trim()?t.stdout.trim():`none`,gitEmail:n.status===0?n.stdout.trim():`none`,tokenSet:!!process.env.GITHUB_TOKEN}},De=(e,n=U)=>{let r=[];he()||r.push({status:`warn`,label:`platform`,detail:`inscope's secret resolution targets macOS (gh keyring + Keychain)`});let i=f(),a=Ce(i);a===null?r.push({status:`fail`,label:`hook`,detail:`missing ${i}; run \`inscope init\``}):a===k(e)?r.push({status:`ok`,label:`hook`,detail:i}):r.push({status:`warn`,label:`hook`,detail:"out of date; run `inscope apply`"}),r.push(C(h(),F)===null?{status:`warn`,label:`zshrc`,detail:"does not source the hook; run `inscope init`"}:{status:`ok`,label:`zshrc`,detail:`sources the hook`}),e.workspaces.some(T)&&r.push(C(m(),w)===null?{status:`fail`,label:`gitconfig`,detail:"missing includeIf block; run `inscope apply`"}:{status:`ok`,label:`gitconfig`,detail:`includeIf block present`});for(let i of e.workspaces){let e=`[${i.name}]`;if(i.gh&&r.push(ge(i.gh,n)?{status:`ok`,label:`${e} gh`,detail:`token for ${i.gh}`}:{status:`fail`,label:`${e} gh`,detail:`no token for ${i.gh}; run \`gh auth login\``}),i.servers.slack){let t=i.servers.slack.keychain;r.push(G(t,n)?{status:`ok`,label:`${e} slack`,detail:t}:{status:`fail`,label:`${e} slack`,detail:`${t} not in keychain; run \`${K(t)}\``})}if(T(i)){let a=E(i.name);if(!t.existsSync(a))r.push({status:`fail`,label:`${e} git`,detail:`missing ${a}; run \`inscope apply\``});else if(i.git?.email){let t=ve(a,n);r.push(t===i.git.email?{status:`ok`,label:`${e} git`,detail:i.git.email}:{status:`fail`,label:`${e} git`,detail:`email is ${t??`unset`}, expected ${i.git.email}`})}}let a=se(i);if(a===null)r.push({status:`warn`,label:`${e} mcp`,detail:"no .mcp.json; run `inscope apply`"});else{let t=j(i.name).filter(e=>a.mcpServers?.[e]);r.push({status:`ok`,label:`${e} mcp`,detail:`${t.length} server(s)`});let n=we(a);n.length&&r.push({status:`warn`,label:`${e} mcp`,detail:`unpinned: ${n.join(`, `)}`})}}return r},Oe=`Verify the setup: gh tokens resolve, keychain entries exist,
|
|
83
|
+
git emails match per path, the hook is current, and no MCP server is unpinned.
|
|
84
|
+
Exits non-zero if any check fails.
|
|
85
|
+
|
|
86
|
+
Usage:
|
|
87
|
+
$ ${q} doctor
|
|
88
|
+
|
|
89
|
+
Options:
|
|
90
|
+
-h, --help Display help message`,ke={ok:`✓`,warn:`!`,fail:`✗`},Ae=t=>{let{values:n}=e({allowPositionals:!0,options:{help:{type:`boolean`,short:`h`}},args:t});n.help&&(console.log(Oe),process.exit(0)),R()||(console.error(`No config found. Run \`${q} init\` first.`),process.exit(1));let r=z(),i=De(r);for(let e of i)console.log(`${ke[e.status]} ${e.label}${e.detail?` ${e.detail}`:``}`);let a=Te(r);if(a){let e=Ee();console.log(`\nThis shell (${a.name}):`),console.log(` pwd ${e.pwd}`),console.log(` gh ${e.gh}`),console.log(` git ${e.gitEmail}`),console.log(` token ${e.tokenSet?`set`:`unset`}`)}let o=i.filter(e=>e.status===`fail`).length;o&&(console.log(`\n${o} check(s) failed.`),process.exit(1)),console.log(`
|
|
91
|
+
All checks passed.`),process.exit(0)},je=`Set up inscope: create the config, generate the chpwd hook, and
|
|
92
|
+
source it from ~/.zshrc. Safe to run again; it never overwrites your config.
|
|
93
|
+
|
|
94
|
+
Usage:
|
|
95
|
+
$ ${q} init
|
|
96
|
+
|
|
97
|
+
Options:
|
|
98
|
+
-h, --help Display help message`,Me=t=>{let{values:n}=e({allowPositionals:!0,options:{help:{type:`boolean`,short:`h`}},args:t});n.help&&(console.log(je),process.exit(0));let r;R()?(r=z(),console.log(`Using existing config at ${d()}`)):(r=L(),B(r),console.log(`Created ${d()}`)),I(r),console.log(`Generated the chpwd hook and added a source line to ~/.zshrc.`),console.log(`
|
|
99
|
+
Next steps:
|
|
100
|
+
1. Reload your shell: source ~/.zshrc (or open a new terminal)
|
|
101
|
+
2. Sign each GitHub account in: gh auth login
|
|
102
|
+
3. Map a workspace: ${q} add ~/acme --gh acme --email you@acme.com
|
|
103
|
+
`),process.exit(0)},Ne=`List the configured workspaces. Run \`${q} doctor\` to verify
|
|
104
|
+
that their tokens and identities actually resolve.
|
|
105
|
+
|
|
106
|
+
Usage:
|
|
107
|
+
$ ${q} list
|
|
108
|
+
|
|
109
|
+
Options:
|
|
110
|
+
-h, --help Display help message`,Z=e=>[e.github&&`github`,e.linear&&`linear`,e.notion&&`notion`,e.slack&&`slack`].filter(Boolean).join(`, `)||`none`,Pe=t=>{let{values:n}=e({allowPositionals:!0,options:{help:{type:`boolean`,short:`h`}},args:t});n.help&&(console.log(Ne),process.exit(0)),R()||(console.error(`No config found. Run \`${q} init\` first.`),process.exit(1));let r=z();r.workspaces.length||(console.log(`No workspaces yet. Add one with \`${q} add <path> --gh <account>\`.`),process.exit(0));for(let e of r.workspaces)console.log(`${e.name}`),console.log(` path ${e.path}`),console.log(` gh ${e.gh??`(none)`}`),console.log(` git ${e.git?.email??`(default)`}`),console.log(` servers ${Z(e.servers)}`),e.servers.slack&&console.log(` slack keychain: ${e.servers.slack.keychain}`);process.exit(0)},Q=`Remove a workspace mapping. Drops its git include and the MCP
|
|
111
|
+
servers inscope manages; leaves your keychain and gh accounts untouched.
|
|
112
|
+
|
|
113
|
+
Usage:
|
|
114
|
+
$ ${q} rm <path|label>
|
|
115
|
+
|
|
116
|
+
Options:
|
|
117
|
+
-h, --help Display help message`,Fe=t=>{let{positionals:n,values:r}=e({allowPositionals:!0,options:{help:{type:`boolean`,short:`h`}},args:t});r.help&&(console.log(Q),process.exit(0));let i=n[0];if(!i)throw Error(Q);R()||(console.error(`No config found. Run \`${q} init\` first.`),process.exit(1));let{cfg:a,removed:o}=me(z(),i);o||(console.error(`No workspace matching "${i}".`),process.exit(1)),le(o),ae(o.name),B(a),I(a),console.log(`✓ removed workspace "${o.name}"`),o.servers.slack&&console.log(`Note: the keychain entry ${o.servers.slack.keychain} was left in place.\nDelete it with: security delete-generic-password -s ${o.servers.slack.keychain}`),process.exit(0)},$=`Version:
|
|
118
|
+
${q}@${J}
|
|
119
|
+
|
|
120
|
+
Per-workspace identity for Claude Code: scope MCP servers, GitHub auth, and git
|
|
121
|
+
commit identity to the directory you are in, so concurrent sessions never clash.
|
|
122
|
+
|
|
123
|
+
Usage:
|
|
124
|
+
$ ${q} <command> [options]
|
|
125
|
+
|
|
126
|
+
Commands:
|
|
127
|
+
init Create the config, generate the hook, source it from ~/.zshrc
|
|
128
|
+
add <path> Map a directory to a GitHub account, git email, and MCP servers
|
|
129
|
+
rm <path> Remove a workspace mapping (alias: remove)
|
|
130
|
+
list List configured workspaces (alias: ls)
|
|
131
|
+
apply Regenerate the hook, git includes, and .mcp.json (alias: sync)
|
|
132
|
+
doctor Verify tokens, identities, and the hook resolve correctly
|
|
133
|
+
|
|
134
|
+
Options:
|
|
135
|
+
-v, --version Display version
|
|
136
|
+
-h, --help Display help
|
|
137
|
+
|
|
138
|
+
Author:
|
|
139
|
+
${Y.name} <${Y.email}> (${Y.url})`;(async()=>{try{let e=process.argv.slice(2),t=e[0],n=e.slice(1);switch(t){case`init`:return Me(n);case`add`:return await be(n);case`rm`:case`remove`:return Fe(n);case`ls`:case`list`:return Pe(n);case`apply`:case`sync`:return Se(n);case`doctor`:return Ae(n)}(t===`-v`||t===`--version`)&&(console.log(`${q}@${J}`),process.exit(0)),(!t||t===`-h`||t===`--help`)&&(console.log($),process.exit(0)),console.error(`unknown command: ${e.join(` `)}\n`),console.error($),process.exit(1)}catch(e){console.error(e.message),process.exit(1)}})();export{};
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
//#region src/config.d.ts
|
|
2
|
+
type SlackServer = {
|
|
3
|
+
keychain: string;
|
|
4
|
+
addMessageTool?: boolean;
|
|
5
|
+
};
|
|
6
|
+
type HttpServer = {
|
|
7
|
+
url?: string;
|
|
8
|
+
};
|
|
9
|
+
type Servers = {
|
|
10
|
+
github?: boolean;
|
|
11
|
+
linear?: boolean | HttpServer;
|
|
12
|
+
notion?: boolean | HttpServer;
|
|
13
|
+
slack?: SlackServer | false;
|
|
14
|
+
};
|
|
15
|
+
type Workspace = {
|
|
16
|
+
name: string;
|
|
17
|
+
path: string;
|
|
18
|
+
gh?: string;
|
|
19
|
+
git?: {
|
|
20
|
+
email?: string;
|
|
21
|
+
name?: string;
|
|
22
|
+
};
|
|
23
|
+
servers: Servers;
|
|
24
|
+
};
|
|
25
|
+
type Config = {
|
|
26
|
+
version: number;
|
|
27
|
+
workspaces: Workspace[];
|
|
28
|
+
};
|
|
29
|
+
declare const CONFIG_VERSION = 1;
|
|
30
|
+
declare const defaultConfig: () => Config;
|
|
31
|
+
declare const configExists: () => boolean;
|
|
32
|
+
declare const loadConfig: () => Config;
|
|
33
|
+
declare const saveConfig: (cfg: Config) => void;
|
|
34
|
+
declare const validateConfig: (cfg: Config) => void;
|
|
35
|
+
declare const labelFromPath: (p: string) => string;
|
|
36
|
+
declare const findWorkspace: (cfg: Config, key: string) => Workspace | undefined;
|
|
37
|
+
declare const upsertWorkspace: (cfg: Config, ws: Workspace) => Config;
|
|
38
|
+
declare const removeWorkspace: (cfg: Config, key: string) => {
|
|
39
|
+
cfg: Config;
|
|
40
|
+
removed?: Workspace;
|
|
41
|
+
};
|
|
42
|
+
//#endregion
|
|
43
|
+
//#region src/apply.d.ts
|
|
44
|
+
declare const ZSHRC_BLOCK_ID = "zshrc";
|
|
45
|
+
declare const ensureZshrcSource: () => void;
|
|
46
|
+
type ApplyResult = {
|
|
47
|
+
hook: string;
|
|
48
|
+
gitconfig: boolean;
|
|
49
|
+
mcp: string[];
|
|
50
|
+
};
|
|
51
|
+
declare const applyAll: (cfg: Config) => ApplyResult;
|
|
52
|
+
//#endregion
|
|
53
|
+
//#region src/secrets.d.ts
|
|
54
|
+
type RunResult = {
|
|
55
|
+
status: number;
|
|
56
|
+
stdout: string;
|
|
57
|
+
stderr: string;
|
|
58
|
+
};
|
|
59
|
+
type Runner = (cmd: string, args: string[], opts?: {
|
|
60
|
+
input?: string;
|
|
61
|
+
}) => RunResult;
|
|
62
|
+
declare const defaultRunner: Runner;
|
|
63
|
+
declare const isMacOS: () => boolean;
|
|
64
|
+
declare const ghToken: (account: string, run?: Runner) => string | null;
|
|
65
|
+
declare const ghStatus: (run?: Runner) => string;
|
|
66
|
+
declare const keychainHas: (service: string, run?: Runner) => boolean;
|
|
67
|
+
declare const keychainSet: (service: string, token: string, run?: Runner) => void;
|
|
68
|
+
declare const keychainSetCommand: (service: string) => string;
|
|
69
|
+
declare const gitEmailForFile: (file: string, run?: Runner) => string | null;
|
|
70
|
+
//#endregion
|
|
71
|
+
//#region src/doctor.d.ts
|
|
72
|
+
type CheckStatus = "ok" | "warn" | "fail";
|
|
73
|
+
type Check = {
|
|
74
|
+
status: CheckStatus;
|
|
75
|
+
label: string;
|
|
76
|
+
detail?: string;
|
|
77
|
+
};
|
|
78
|
+
declare const currentWorkspace: (cfg: Config, cwd?: string) => Workspace | undefined;
|
|
79
|
+
declare const liveSnapshot: (run?: Runner) => {
|
|
80
|
+
pwd: string;
|
|
81
|
+
gh: string;
|
|
82
|
+
gitEmail: string;
|
|
83
|
+
tokenSet: boolean;
|
|
84
|
+
};
|
|
85
|
+
declare const runDoctor: (cfg: Config, run?: Runner) => Check[];
|
|
86
|
+
//#endregion
|
|
87
|
+
//#region src/generators/hook.d.ts
|
|
88
|
+
declare const renderHook: (cfg: Config) => string;
|
|
89
|
+
//#endregion
|
|
90
|
+
//#region src/generators/gitconfig.d.ts
|
|
91
|
+
declare const renderGitInclude: (cfg: Config) => string;
|
|
92
|
+
declare const renderPerWorkspaceGitconfig: (ws: Workspace) => string;
|
|
93
|
+
declare const applyGitconfig: (cfg: Config) => void;
|
|
94
|
+
//#endregion
|
|
95
|
+
//#region src/generators/mcp.d.ts
|
|
96
|
+
declare const SLACK_MCP_VERSION = "1.3.0";
|
|
97
|
+
declare const managedKeys: (name: string) => string[];
|
|
98
|
+
declare const mcpFilePath: (ws: Workspace) => string;
|
|
99
|
+
declare const renderServers: (ws: Workspace) => Record<string, unknown>;
|
|
100
|
+
declare const renderMcp: (ws: Workspace) => {
|
|
101
|
+
mcpServers: Record<string, unknown>;
|
|
102
|
+
};
|
|
103
|
+
declare const readMcp: (ws: Workspace) => Record<string, any> | null;
|
|
104
|
+
declare const applyMcp: (ws: Workspace) => void;
|
|
105
|
+
declare const removeMcp: (ws: Workspace) => void;
|
|
106
|
+
//#endregion
|
|
107
|
+
export { ApplyResult, CONFIG_VERSION, Check, CheckStatus, Config, HttpServer, RunResult, Runner, SLACK_MCP_VERSION, Servers, SlackServer, Workspace, ZSHRC_BLOCK_ID, applyAll, applyGitconfig, applyMcp, configExists, currentWorkspace, defaultConfig, defaultRunner, ensureZshrcSource, findWorkspace, ghStatus, ghToken, gitEmailForFile, isMacOS, keychainHas, keychainSet, keychainSetCommand, labelFromPath, liveSnapshot, loadConfig, managedKeys, mcpFilePath, readMcp, removeMcp, removeWorkspace, renderGitInclude, renderHook, renderMcp, renderPerWorkspaceGitconfig, renderServers, runDoctor, saveConfig, upsertWorkspace, validateConfig };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import e from"node:fs";import t from"node:path";import n from"node:os";import{spawnSync as r}from"node:child_process";const i=()=>n.homedir(),a=()=>process.env.XDG_CONFIG_HOME?.trim()||t.join(i(),`.config`),o=e=>e===`~`?i():e.startsWith(`~/`)?t.join(i(),e.slice(2)):e,s=e=>{let n=o(e),r=i();return n===r?`~`:n.startsWith(r+t.sep)?`~/`+n.slice(r.length+1):n},c=e=>t.resolve(o(e)),l=()=>t.join(a(),`claude`,`workspaces.json`),u=()=>t.join(a(),`claude`,`mcp-tokens.zsh`),d=()=>t.join(a(),`git`),f=()=>t.join(i(),`.gitconfig`),p=()=>t.join(i(),`.zshrc`),ee=1,te=()=>({version:1,workspaces:[]}),m=()=>e.existsSync(l()),h=()=>{let t=l(),n=e.readFileSync(t,`utf8`),r=JSON.parse(n);return _(r),r},g=n=>{let r=l();e.mkdirSync(t.dirname(r),{recursive:!0}),e.writeFileSync(r,JSON.stringify(n,null,2)+`
|
|
2
|
+
`)},_=e=>{if(!e||typeof e!=`object`)throw Error(`config is not an object`);if(!Array.isArray(e.workspaces))throw Error(`config.workspaces must be an array`);let t=new Set;for(let n of e.workspaces){if(!n.name)throw Error(`a workspace is missing a name`);if(!n.path)throw Error(`workspace "${n.name}" is missing a path`);if(t.has(n.name))throw Error(`duplicate workspace name "${n.name}"`);t.add(n.name)}},ne=e=>t.basename(c(e)),v=(e,t)=>{let n=e.workspaces.find(e=>e.name===t);if(n)return n;let r=c(t);return e.workspaces.find(e=>c(e.path)===r)},re=(e,t)=>{let n=e.workspaces.filter(e=>e.name!==t.name);return n.push({...t,path:s(t.path)}),n.sort((e,t)=>e.name.localeCompare(t.name)),{...e,workspaces:n}},ie=(e,t)=>{let n=v(e,t);return n?{cfg:{...e,workspaces:e.workspaces.filter(e=>e.name!==n.name)},removed:n}:{cfg:e}},y=e=>`# >>> inscope:${e} >>>`,b=e=>`# <<< inscope:${e} <<<`,x=e=>e.replace(/[.*+?^${}()|[\]\\]/g,`\\$&`),S=e=>RegExp(`${x(y(e))}\\n[\\s\\S]*?\\n${x(b(e))}\\n?`),ae=(e,t)=>{let n=t.replace(/\n+$/,``);return`${y(e)}\n${n}\n${b(e)}\n`},C=t=>{try{return e.readFileSync(t,`utf8`)}catch{return``}},w=(n,r,i)=>{e.mkdirSync(t.dirname(n),{recursive:!0});let a=C(n),o=ae(r,i),s=S(r),c;if(s.test(a))c=a.replace(s,o);else{let e=a.replace(/\n*$/,``);c=e.length?`${e}\n\n${o}`:o}e.writeFileSync(n,c)},oe=(t,n)=>{let r=C(t);if(!r)return;let i=r.replace(S(n),``).replace(/\n{3,}/g,`
|
|
3
|
+
|
|
4
|
+
`).replace(/^\n+/,``);e.writeFileSync(t,i)},T=(e,t)=>{let n=C(e).match(RegExp(`${x(y(t))}\\n([\\s\\S]*?)\\n${x(b(t))}`));return n?n[1]:null},E=`gitconfig`,D=e=>!!(e.git&&(e.git.email||e.git.name)),O=e=>t.join(d(),`${e}.gitconfig`),k=e=>s(e).replace(/\/+$/,``)+`/`,A=e=>e.workspaces.filter(D).map(e=>`[includeIf "gitdir:${k(e.path)}"]\n\tpath = ${s(O(e.name))}`).join(`
|
|
5
|
+
`),j=e=>{let t=[`# Managed by inscope. Do not edit by hand.`,`[user]`];return e.git?.email&&t.push(`\temail = ${e.git.email}`),e.git?.name&&t.push(`\tname = ${e.git.name}`),t.join(`
|
|
6
|
+
`)+`
|
|
7
|
+
`},M=t=>{e.mkdirSync(d(),{recursive:!0});for(let n of t.workspaces)D(n)&&e.writeFileSync(O(n.name),j(n));let n=A(t);n?w(f(),E,n):oe(f(),E)},se=e=>{let t=s(e);return t===`~`?`"$HOME/"*`:t.startsWith(`~/`)?`"$HOME/${t.slice(2)}/"*`:`"${t}/"*`},ce=e=>e.servers.slack?e.servers.slack.keychain:``,N=e=>{let t=[...e.workspaces].sort((e,t)=>e.name.localeCompare(t.name));return`# Managed by inscope. Do not edit by hand.
|
|
8
|
+
# Source of truth: ~/.config/claude/workspaces.json
|
|
9
|
+
# Edit there, then run \`inscope apply\` to regenerate this file.
|
|
10
|
+
#
|
|
11
|
+
# One chpwd hook resolves per-workspace secrets from \$PWD on every cd: it maps
|
|
12
|
+
# the current directory to a workspace, then pulls that workspace's GitHub token
|
|
13
|
+
# from the gh keyring and Slack token from the macOS keychain. Nothing sensitive
|
|
14
|
+
# is written to disk, and there is no shared mutable state for sessions to race.
|
|
15
|
+
__inscope_resolve_identity() {
|
|
16
|
+
local ws
|
|
17
|
+
case "\${PWD}/" in
|
|
18
|
+
${t.map(e=>` ${se(e.path)}) ws=${e.name} ;;`).join(`
|
|
19
|
+
`)||` # no workspaces configured`}
|
|
20
|
+
*) ws="" ;;
|
|
21
|
+
esac
|
|
22
|
+
[[ "$ws" == "$__inscope_ws" ]] && return # workspace unchanged, skip the lookups
|
|
23
|
+
__inscope_ws="$ws"
|
|
24
|
+
unset GITHUB_TOKEN GH_TOKEN SLACK_MCP_XOXP_TOKEN # clear previous (and any inherited) tokens
|
|
25
|
+
|
|
26
|
+
local gh_user="" slack_svc=""
|
|
27
|
+
case "$ws" in
|
|
28
|
+
${t.map(e=>{let t=[];e.gh&&t.push(`gh_user=${e.gh}`);let n=ce(e);return n&&t.push(`slack_svc=${n}`),` ${e.name}) ${t.length?t.join(`; `):`:`} ;;`}).join(`
|
|
29
|
+
`)||` # no workspaces configured`}
|
|
30
|
+
*) return ;; # outside a mapped workspace: nothing set
|
|
31
|
+
esac
|
|
32
|
+
|
|
33
|
+
local tok
|
|
34
|
+
if [[ -n "$gh_user" ]]; then
|
|
35
|
+
if tok="$(gh auth token -u "$gh_user" 2>/dev/null)" && [[ -n "$tok" ]]; then
|
|
36
|
+
export GITHUB_TOKEN="$tok" GH_TOKEN="$tok"
|
|
37
|
+
else
|
|
38
|
+
print -u2 "inscope: no gh token for $gh_user; GITHUB_TOKEN/GH_TOKEN unset"
|
|
39
|
+
fi
|
|
40
|
+
fi
|
|
41
|
+
if [[ -n "$slack_svc" ]]; then
|
|
42
|
+
if tok="$(security find-generic-password -a "$USER" -s "$slack_svc" -w 2>/dev/null)" && [[ -n "$tok" ]]; then
|
|
43
|
+
export SLACK_MCP_XOXP_TOKEN="$tok"
|
|
44
|
+
else
|
|
45
|
+
print -u2 "inscope: $slack_svc not in keychain; SLACK_MCP_XOXP_TOKEN unset"
|
|
46
|
+
fi
|
|
47
|
+
fi
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
autoload -Uz add-zsh-hook
|
|
51
|
+
add-zsh-hook chpwd __inscope_resolve_identity
|
|
52
|
+
__inscope_ws="__init__" # force the first resolve, clearing any inherited token
|
|
53
|
+
__inscope_resolve_identity
|
|
54
|
+
`},P=`1.3.0`,le=[`github`,`linear`,`notion`,`slack`],F=e=>le.map(t=>`${t}-${e}`),I=e=>t.join(c(e.path),`.mcp.json`),L=(e,t)=>e&&typeof e==`object`&&e.url?e.url:t,R=e=>{let t=e.servers,n={};if(t.github&&(n[`github-${e.name}`]={type:`http`,url:`https://api.githubcopilot.com/mcp/`,headers:{Authorization:"Bearer ${GITHUB_TOKEN}"}}),t.linear&&(n[`linear-${e.name}`]={type:`http`,url:L(t.linear,`https://mcp.linear.app/mcp`)}),t.notion&&(n[`notion-${e.name}`]={type:`http`,url:L(t.notion,`https://mcp.notion.com/mcp`)}),t.slack){let r={SLACK_MCP_XOXP_TOKEN:"${SLACK_MCP_XOXP_TOKEN}"};t.slack.addMessageTool&&(r.SLACK_MCP_ADD_MESSAGE_TOOL=`true`),n[`slack-${e.name}`]={type:`stdio`,command:`npx`,args:[`-y`,`slack-mcp-server@${P}`,`--transport`,`stdio`],env:r}}return n},z=e=>({mcpServers:R(e)}),B=t=>{if(!e.existsSync(t))return{};try{return JSON.parse(e.readFileSync(t,`utf8`))}catch{return{}}},V=t=>{let n=I(t);return e.existsSync(n)?B(n):null},H=n=>{let r=I(n);e.mkdirSync(t.dirname(r),{recursive:!0});let i=B(r),a=i.mcpServers&&typeof i.mcpServers==`object`?{...i.mcpServers}:{};for(let e of F(n.name))delete a[e];Object.assign(a,R(n)),i.mcpServers=a,e.writeFileSync(r,JSON.stringify(i,null,2)+`
|
|
55
|
+
`)},U=t=>{let n=I(t);if(!e.existsSync(n))return;let r=B(n);if(r.mcpServers&&typeof r.mcpServers==`object`)for(let e of F(t.name))delete r.mcpServers[e];e.writeFileSync(n,JSON.stringify(r,null,2)+`
|
|
56
|
+
`)},W=`zshrc`,ue=e=>{let n=i();return e===n?`$HOME`:e.startsWith(n+t.sep)?`$HOME/${e.slice(n.length+1)}`:e},G=()=>{let e=ue(u()),t=`# Loads each workspace's tokens (GitHub, Slack) from $PWD on every cd.\n[ -r "${e}" ] && source "${e}"`;w(p(),W,t)},de=n=>{let r=u();e.mkdirSync(t.dirname(r),{recursive:!0}),e.writeFileSync(r,N(n)),M(n),G();let i=[];for(let e of n.workspaces)H(e),i.push(I(e));return{hook:r,gitconfig:n.workspaces.some(e=>e.git?.email||e.git?.name),mcp:i}},K=(e,t,n)=>{let i=r(e,t,{encoding:`utf8`,input:n?.input});return{status:i.status??(i.error?127:1),stdout:i.stdout??``,stderr:i.stderr??``}},q=()=>process.platform===`darwin`,J=()=>process.env.USER||``,Y=(e,t=K)=>{let n=t(`gh`,[`auth`,`token`,`-u`,e]),r=n.stdout.trim();return n.status===0&&r?r:null},fe=(e=K)=>{let t=e(`gh`,[`auth`,`status`]);return(t.stdout+t.stderr).trim()},X=(e,t=K)=>{let n=t(`security`,[`find-generic-password`,`-a`,J(),`-s`,e,`-w`]);return n.status===0&&n.stdout.trim().length>0},pe=(e,t,n=K)=>{let r=n(`security`,[`add-generic-password`,`-U`,`-a`,J(),`-s`,e,`-w`,t]);if(r.status!==0)throw Error(`security add-generic-password failed: ${r.stderr.trim()||`unknown error`}`)},Z=e=>`security add-generic-password -U -a "${J()||`$USER`}" -s ${e} -w 'xoxp-...'`,Q=(e,t=K)=>{let n=t(`git`,[`config`,`--file`,e,`user.email`]);return n.status===0?n.stdout.trim():null},me=t=>{try{return e.readFileSync(t,`utf8`)}catch{return null}},he=e=>{let t=[],n=e?.mcpServers;if(!n||typeof n!=`object`)return t;for(let[e,r]of Object.entries(n)){let n=Array.isArray(r?.args)?r.args:[];if(n.some(e=>typeof e==`string`&&/@latest$/.test(e)))t.push(e);else if(r?.command===`npx`){let r=n.find(e=>typeof e==`string`&&!e.startsWith(`-`));r&&!r.includes(`@`)&&t.push(e)}}return t},ge=(e,n=process.cwd())=>{let r=t.resolve(n);return e.workspaces.find(e=>{let n=c(e.path);return r===n||r.startsWith(n+t.sep)})},$=(e=K)=>{let t=e(`gh`,[`api`,`user`,`--jq`,`.login`]),n=e(`git`,[`config`,`user.email`]);return{pwd:process.cwd(),gh:t.status===0&&t.stdout.trim()?t.stdout.trim():`none`,gitEmail:n.status===0?n.stdout.trim():`none`,tokenSet:!!process.env.GITHUB_TOKEN}},_e=(t,n=K)=>{let r=[];q()||r.push({status:`warn`,label:`platform`,detail:`inscope's secret resolution targets macOS (gh keyring + Keychain)`});let i=u(),a=me(i);a===null?r.push({status:`fail`,label:`hook`,detail:`missing ${i}; run \`inscope init\``}):a===N(t)?r.push({status:`ok`,label:`hook`,detail:i}):r.push({status:`warn`,label:`hook`,detail:"out of date; run `inscope apply`"}),r.push(T(p(),W)===null?{status:`warn`,label:`zshrc`,detail:"does not source the hook; run `inscope init`"}:{status:`ok`,label:`zshrc`,detail:`sources the hook`}),t.workspaces.some(D)&&r.push(T(f(),E)===null?{status:`fail`,label:`gitconfig`,detail:"missing includeIf block; run `inscope apply`"}:{status:`ok`,label:`gitconfig`,detail:`includeIf block present`});for(let i of t.workspaces){let t=`[${i.name}]`;if(i.gh&&r.push(Y(i.gh,n)?{status:`ok`,label:`${t} gh`,detail:`token for ${i.gh}`}:{status:`fail`,label:`${t} gh`,detail:`no token for ${i.gh}; run \`gh auth login\``}),i.servers.slack){let e=i.servers.slack.keychain;r.push(X(e,n)?{status:`ok`,label:`${t} slack`,detail:e}:{status:`fail`,label:`${t} slack`,detail:`${e} not in keychain; run \`${Z(e)}\``})}if(D(i)){let a=O(i.name);if(!e.existsSync(a))r.push({status:`fail`,label:`${t} git`,detail:`missing ${a}; run \`inscope apply\``});else if(i.git?.email){let e=Q(a,n);r.push(e===i.git.email?{status:`ok`,label:`${t} git`,detail:i.git.email}:{status:`fail`,label:`${t} git`,detail:`email is ${e??`unset`}, expected ${i.git.email}`})}}let a=V(i);if(a===null)r.push({status:`warn`,label:`${t} mcp`,detail:"no .mcp.json; run `inscope apply`"});else{let e=F(i.name).filter(e=>a.mcpServers?.[e]);r.push({status:`ok`,label:`${t} mcp`,detail:`${e.length} server(s)`});let n=he(a);n.length&&r.push({status:`warn`,label:`${t} mcp`,detail:`unpinned: ${n.join(`, `)}`})}}return r};export{ee as CONFIG_VERSION,P as SLACK_MCP_VERSION,W as ZSHRC_BLOCK_ID,de as applyAll,M as applyGitconfig,H as applyMcp,m as configExists,ge as currentWorkspace,te as defaultConfig,K as defaultRunner,G as ensureZshrcSource,v as findWorkspace,fe as ghStatus,Y as ghToken,Q as gitEmailForFile,q as isMacOS,X as keychainHas,pe as keychainSet,Z as keychainSetCommand,ne as labelFromPath,$ as liveSnapshot,h as loadConfig,F as managedKeys,I as mcpFilePath,V as readMcp,U as removeMcp,ie as removeWorkspace,A as renderGitInclude,N as renderHook,z as renderMcp,j as renderPerWorkspaceGitconfig,R as renderServers,_e as runDoctor,g as saveConfig,re as upsertWorkspace,_ as validateConfig};
|
package/package.json
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "inscope",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Per-workspace identity for Claude Code: scope MCP servers, GitHub auth, and git commit identity to the directory you are in.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"claude-code",
|
|
7
|
+
"mcp",
|
|
8
|
+
"cli",
|
|
9
|
+
"git",
|
|
10
|
+
"github",
|
|
11
|
+
"identity",
|
|
12
|
+
"workspace",
|
|
13
|
+
"zsh",
|
|
14
|
+
"chpwd"
|
|
15
|
+
],
|
|
16
|
+
"homepage": "https://github.com/nrjdalal/inscope#readme",
|
|
17
|
+
"bugs": "https://github.com/nrjdalal/inscope/issues",
|
|
18
|
+
"repository": "nrjdalal/inscope",
|
|
19
|
+
"funding": "https://github.com/sponsors/nrjdalal",
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"author": {
|
|
22
|
+
"name": "Neeraj Dalal",
|
|
23
|
+
"email": "admin@nrjdalal.com",
|
|
24
|
+
"url": "https://nrjdalal.com"
|
|
25
|
+
},
|
|
26
|
+
"type": "module",
|
|
27
|
+
"exports": "./dist/index.mjs",
|
|
28
|
+
"types": "./dist/index.d.mts",
|
|
29
|
+
"bin": {
|
|
30
|
+
"inscope": "./dist/bin/index.mjs"
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"dist"
|
|
34
|
+
],
|
|
35
|
+
"scripts": {
|
|
36
|
+
"bin": "tsdown && node dist/bin/index.mjs",
|
|
37
|
+
"build": "tsdown",
|
|
38
|
+
"dev": "tsdown --watch",
|
|
39
|
+
"test": "bun test",
|
|
40
|
+
"typecheck": "tsc --noEmit",
|
|
41
|
+
"prepare": "npx simple-git-hooks"
|
|
42
|
+
},
|
|
43
|
+
"simple-git-hooks": {
|
|
44
|
+
"pre-commit": "npx lint-staged",
|
|
45
|
+
"commit-msg": "npx commitlint --edit $1"
|
|
46
|
+
},
|
|
47
|
+
"commitlint": {
|
|
48
|
+
"extends": [
|
|
49
|
+
"@commitlint/config-conventional"
|
|
50
|
+
]
|
|
51
|
+
},
|
|
52
|
+
"lint-staged": {
|
|
53
|
+
"*": "prettier --write --ignore-unknown",
|
|
54
|
+
"package.json": "sort-package-json"
|
|
55
|
+
},
|
|
56
|
+
"prettier": {
|
|
57
|
+
"plugins": [
|
|
58
|
+
"@ianvs/prettier-plugin-sort-imports"
|
|
59
|
+
],
|
|
60
|
+
"semi": false
|
|
61
|
+
},
|
|
62
|
+
"devDependencies": {
|
|
63
|
+
"@commitlint/cli": "^19.8.1",
|
|
64
|
+
"@commitlint/config-conventional": "^19.8.1",
|
|
65
|
+
"@ianvs/prettier-plugin-sort-imports": "^4.4.2",
|
|
66
|
+
"@types/node": "^22.15.32",
|
|
67
|
+
"lint-staged": "^15.5.2",
|
|
68
|
+
"prettier": "^3.5.3",
|
|
69
|
+
"simple-git-hooks": "^2.13.0",
|
|
70
|
+
"sort-package-json": "^3.2.1",
|
|
71
|
+
"tsdown": "^0.16.4",
|
|
72
|
+
"typescript": "^5.8.3"
|
|
73
|
+
}
|
|
74
|
+
}
|