inscope 0.1.1 โ†’ 0.2.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/README.md CHANGED
@@ -1,41 +1,107 @@
1
- # inscope
1
+ # Inscope
2
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.
3
+ **Per-workspace identity for [Claude Code](https://claude.com/claude-code): scope MCP servers, GitHub auth, and git commit identity to the directory you are in.**
7
4
 
8
- You describe each workspace once; `inscope` owns the moving parts and keeps them
9
- in sync:
5
+ [![Twitter](https://img.shields.io/twitter/follow/nrjdalal_dev?label=%40nrjdalal_dev)](https://twitter.com/nrjdalal_dev)
6
+ [![npm](https://img.shields.io/npm/v/inscope?color=red&logo=npm)](https://www.npmjs.com/package/inscope)
7
+ [![downloads](https://img.shields.io/npm/dt/inscope?color=red&logo=npm)](https://www.npmjs.com/package/inscope)
8
+ [![stars](https://img.shields.io/github/stars/nrjdalal/inscope?color=blue)](https://github.com/nrjdalal/inscope)
9
+
10
+ > #### `cd` into a project and you are the right person: the right GitHub token, the right MCP servers, the right git commit email, all resolved live from `$PWD`. No toggles, no profile switching, and it holds up with several Claude Code sessions open at once.
11
+
12
+ Concurrent sessions in different projects should never bleed work and personal accounts into each other. You describe each workspace once; `inscope` owns the moving parts and keeps them in sync:
10
13
 
11
14
  - a `.mcp.json` at each workspace root, with uniquely named servers
12
15
  - a single zsh `chpwd` hook that resolves the right tokens from `$PWD`
13
16
  - git `includeIf` rules so commits get the right email per path
14
17
 
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.
18
+ Nothing sensitive is written to disk. GitHub tokens come from the `gh` keyring and Slack tokens from the macOS Keychain, resolved live by the hook.
19
+
20
+ > Background and the why behind the design: [Race-Free Identity in Claude Code](https://zerostarter.dev/blog/mcp-per-workspace).
21
+
22
+ ---
23
+
24
+ ### Table of Contents
25
+
26
+ - [Some Examples](#-some-examples)
27
+ - [Features](#-features)
28
+ - [Requirements](#-requirements)
29
+ - [Quick Usage](#-quick-usage)
30
+ - [Commands](#-commands)
31
+ - [What It Manages](#-what-it-manages)
32
+ - [MCP Servers](#-mcp-servers)
33
+ - [Config File](#-config-file)
34
+ - [Contributing](#-contributing)
35
+
36
+ ---
37
+
38
+ ## ๐Ÿ“– Some Examples
39
+
40
+ ```sh
41
+ # set up the config + hook, and source it from ~/.zshrc
42
+ inscope init
43
+
44
+ # map a workspace interactively: pick the gh account, git identity, and servers
45
+ inscope add ~/acme
46
+
47
+ # or pass flags to skip the prompts (work gh account, work email, + slack)
48
+ inscope add ~/acme --gh acme --email you@acme.com --servers github,linear,notion,slack
49
+
50
+ # map a personal directory: just your gh account and personal email
51
+ inscope add ~/nrjdalal --gh nrjdalal --email you@personal.dev
52
+
53
+ # list what is configured
54
+ inscope list
55
+
56
+ # verify tokens, identities, and the hook all resolve
57
+ inscope doctor
58
+
59
+ # regenerate everything after editing the config by hand
60
+ inscope apply
61
+
62
+ # remove a workspace mapping
63
+ inscope rm ~/acme
64
+ ```
65
+
66
+ `cd ~/acme/api` and you are the work account, with work MCP servers and your work commit email. `cd ~/nrjdalal/blog` and you are you.
67
+
68
+ ---
69
+
70
+ ## โœจ Features
71
+
72
+ - ๐Ÿชช Per-directory identity: GitHub token, git commit email, and MCP servers scoped to `$PWD`
73
+ - ๐Ÿงต Race-free across concurrent shells and Claude Code sessions, with no global toggles
74
+ - ๐Ÿ” No secrets on disk: GitHub tokens from the `gh` keyring, Slack tokens from the macOS Keychain
75
+ - ๐Ÿค– Generates a `.mcp.json` per workspace with uniquely named GitHub, Linear, Notion and Slack servers
76
+ - โœ‰๏ธ Git `includeIf` rules so every commit lands with the right author email per path
77
+ - ๐Ÿช A single zsh `chpwd` hook does all the resolution; nothing else touches your shell
78
+ - ๐Ÿฉบ `inscope doctor` verifies tokens, identities, and the hook before you trust them
79
+ - โ™ป๏ธ Idempotent and surgical: only the managed blocks in `.zshrc`, `.gitconfig` and `.mcp.json` are touched
80
+
81
+ ---
17
82
 
18
- > Background and the why behind the design:
19
- > [Race-Free Identity in Claude Code](https://zerostarter.dev/blog/mcp-per-workspace).
83
+ ## ๐Ÿงฐ Requirements
20
84
 
21
- ## Requirements
85
+ macOS, zsh, [`gh`](https://cli.github.com), and [Claude Code](https://claude.com/claude-code).
22
86
 
23
- macOS, zsh, [`gh`](https://cli.github.com), and Claude Code.
87
+ ---
24
88
 
25
- ## Install
89
+ ## ๐Ÿš€ Quick Usage
26
90
 
27
- ```bash
91
+ Install globally (the CLI manages your shell hook, so a global install is expected):
92
+
93
+ ```sh
28
94
  npm i -g inscope
29
95
  ```
30
96
 
31
- ## Quickstart
97
+ Then walk through the setup once:
32
98
 
33
- ```bash
99
+ ```sh
34
100
  # 1. set up the config + hook, and source it from ~/.zshrc
35
101
  inscope init
36
102
 
37
- # 2. sign each GitHub account into gh (once)
38
- gh auth login # repeat per account
103
+ # 2. sign each GitHub account into gh (once per account)
104
+ gh auth login
39
105
 
40
106
  # 3. map your workspaces
41
107
  inscope add ~/acme --gh acme --email you@acme.com --servers github,linear,notion,slack
@@ -46,36 +112,117 @@ source ~/.zshrc
46
112
  inscope doctor
47
113
  ```
48
114
 
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.
115
+ Launch `claude` from inside a mapped directory (or relaunch) to pick up the identity. No toggles, and it holds up with several terminals open at once.
116
+
117
+ ---
52
118
 
53
- ## Commands
119
+ ## ๐Ÿ”ง Commands
54
120
 
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 |
121
+ ```
122
+ inscope init Create the config, generate the hook, source it from ~/.zshrc
123
+ inscope add <path> Map a directory to a GitHub account, git email, and MCP servers
124
+ inscope rm <path> Remove a workspace mapping (alias: remove)
125
+ inscope list List configured workspaces (alias: ls)
126
+ inscope apply Regenerate the hook, git includes, and .mcp.json (alias: sync)
127
+ inscope doctor Verify tokens, identities, and the hook resolve correctly
128
+
129
+ -v, --version Display version
130
+ -h, --help Display help
131
+ ```
63
132
 
64
133
  Run any command with `-h` for its options.
65
134
 
66
- ## What it manages
135
+ ### `inscope add`
136
+
137
+ Run it bare and it walks you through everything: pick the GitHub account from your signed-in `gh` accounts, accept your global git identity or set a per-workspace one, and toggle which MCP servers to enable. Pass any flag to skip its prompt, or `-y` to take the defaults non-interactively (for scripts and CI).
138
+
139
+ ```
140
+ --gh <account> gh account whose token this workspace uses
141
+ --email <email> git commit email (omit to inherit your global identity)
142
+ --git-name <name> git commit author name (omit to inherit global)
143
+ --label <name> workspace name; defaults to the directory basename
144
+ --servers <list> comma-separated: github,linear,notion,slack
145
+ (default: github)
146
+ --slack-keychain <s> keychain service for the Slack token
147
+ (default: SLACK_MCP_XOXP_TOKEN_<LABEL> when slack is on)
148
+ --slack-message allow the Slack MCP server to post messages
149
+ --seed-slack prompt for the Slack token and store it in the keychain
150
+ -y, --yes accept defaults, skip all prompts (non-interactive)
151
+ ```
152
+
153
+ ---
154
+
155
+ ## ๐Ÿงฉ What It Manages
156
+
157
+ | Surface | Location |
158
+ | ------------ | ------------------------------------------------------------------- |
159
+ | Config | `~/.config/inscope/inscope.json` |
160
+ | chpwd hook | `~/.config/inscope/inscope.zsh` |
161
+ | MCP servers | `<workspace>/.mcp.json` |
162
+ | Git identity | `~/.gitconfig` includeIf + `~/.config/inscope/git/<name>.gitconfig` |
163
+
164
+ `inscope` only touches the blocks it owns; your other `.zshrc`, `.gitconfig` and `.mcp.json` content is left alone. Edit `inscope.json` by hand if you like, then run `inscope apply`.
165
+
166
+ ---
167
+
168
+ ## ๐Ÿค– MCP Servers
169
+
170
+ Each enabled server is written into the workspace `.mcp.json` with a name suffixed by the workspace label (for example `github-acme`), so servers from different workspaces never collide.
171
+
172
+ | Server | Transport | Token source |
173
+ | -------- | --------- | ---------------------------------------------- |
174
+ | `github` | http | `GITHUB_TOKEN` from the active `gh` account |
175
+ | `linear` | http | OAuth via the Linear MCP endpoint |
176
+ | `notion` | http | OAuth via the Notion MCP endpoint |
177
+ | `slack` | stdio | `SLACK_MCP_XOXP_TOKEN` from the macOS Keychain |
178
+
179
+ Slack is opt-in. Enable it with `--servers ...,slack`, then store the token once:
180
+
181
+ ```sh
182
+ inscope add ~/acme --gh acme --servers github,linear,notion,slack --seed-slack
183
+ ```
184
+
185
+ `--seed-slack` prompts for the `xoxp` token and writes it to the Keychain. Pass `--slack-message` to allow the Slack MCP server to post messages.
186
+
187
+ ---
188
+
189
+ ## ๐Ÿ“‹ Config File
190
+
191
+ The source of truth is `~/.config/inscope/inscope.json`:
192
+
193
+ ```jsonc
194
+ {
195
+ "version": 1,
196
+ "workspaces": [
197
+ {
198
+ "name": "acme",
199
+ "path": "~/acme",
200
+ "gh": "acme",
201
+ "git": { "email": "you@acme.com" },
202
+ "servers": {
203
+ "github": true,
204
+ "linear": true,
205
+ "notion": true,
206
+ "slack": {
207
+ "keychain": "SLACK_MCP_XOXP_TOKEN_ACME",
208
+ "addMessageTool": false,
209
+ },
210
+ },
211
+ },
212
+ ],
213
+ }
214
+ ```
215
+
216
+ Edit it directly, then run `inscope apply` to regenerate the hook, git includes, and every `.mcp.json`. `inscope doctor` will tell you if anything no longer resolves.
217
+
218
+ ---
219
+
220
+ ## ๐Ÿค Contributing
67
221
 
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` |
222
+ Issues and pull requests are welcome. Run the tests with `bun test` and the type checks with `bun run typecheck` before opening a PR.
74
223
 
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.
224
+ ---
78
225
 
79
226
  ## License
80
227
 
81
- MIT
228
+ [MIT](./LICENSE) ยฉ [Neeraj Dalal](https://nrjdalal.com)
@@ -1,11 +1,11 @@
1
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,`
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``}},ee=(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)},te=(e,n)=>{let r=x(e);if(!r)return;let i=r.replace(y(n),``).replace(/\n{3,}/g,`
3
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(`
4
+ `).replace(/^\n+/,``);t.writeFileSync(e,i)},ne=(e,t)=>{let n=x(e).match(RegExp(`${v(g(t))}\\n([\\s\\S]*?)\\n${v(_(t))}`));return n?n[1]:null},S=`gitconfig`,C=e=>!!(e.git&&(e.git.email||e.git.name)),w=e=>n.join(p(),`${e}.gitconfig`),re=e=>l(e).replace(/\/+$/,``)+`/`,ie=e=>e.workspaces.filter(C).map(e=>`[includeIf "gitdir:${re(e.path)}"]\n\tpath = ${l(w(e.name))}`).join(`
5
+ `),ae=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
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
7
+ `},oe=e=>{t.mkdirSync(p(),{recursive:!0});for(let n of e.workspaces)C(n)&&t.writeFileSync(w(n.name),ae(n));let n=ie(e);n?ee(m(),S,n):te(m(),S)},se=e=>{let n=w(e);t.existsSync(n)&&t.rmSync(n)},ce=e=>{let t=l(e);return t===`~`?`"$HOME/"*`:t.startsWith(`~/`)?`"$HOME/${t.slice(2)}/"*`:`"${t}/"*`},le=e=>e.servers.slack?e.servers.slack.keychain:``,T=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/inscope/inscope.json
9
9
  # Edit there, then run \`inscope apply\` to regenerate this file.
10
10
  #
11
11
  # One chpwd hook resolves per-workspace secrets from \$PWD on every cd: it maps
@@ -15,7 +15,7 @@ import{parseArgs as e}from"node:util";import t from"node:fs";import n from"node:
15
15
  __inscope_resolve_identity() {
16
16
  local ws
17
17
  case "\${PWD}/" in
18
- ${t.map(e=>` ${D(e.path)}) ws=${e.name} ;;`).join(`
18
+ ${t.map(e=>` ${ce(e.path)}) ws=${e.name} ;;`).join(`
19
19
  `)||` # no workspaces configured`}
20
20
  *) ws="" ;;
21
21
  esac
@@ -25,7 +25,7 @@ ${t.map(e=>` ${D(e.path)}) ws=${e.name} ;;`).join(`
25
25
 
26
26
  local gh_user="" slack_svc=""
27
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(`
28
+ ${t.map(e=>{let t=[];e.gh&&t.push(`gh_user=${e.gh}`);let n=le(e);return n&&t.push(`slack_svc=${n}`),` ${e.name}) ${t.length?t.join(`; `):`:`} ;;`}).join(`
29
29
  `)||` # no workspaces configured`}
30
30
  *) return ;; # outside a mapped workspace: nothing set
31
31
  esac
@@ -51,35 +51,41 @@ autoload -Uz add-zsh-hook
51
51
  add-zsh-hook chpwd __inscope_resolve_identity
52
52
  __inscope_ws="__init__" # force the first resolve, clearing any inherited token
53
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.1`,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.
54
+ `},ue=[`github`,`linear`,`notion`,`slack`],E=e=>ue.map(t=>`${t}-${e}`),D=e=>n.join(u(e.path),`.mcp.json`),O=(e,t)=>e&&typeof e==`object`&&e.url?e.url:t,de=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:O(t.linear,`https://mcp.linear.app/mcp`)}),t.notion&&(n[`notion-${e.name}`]={type:`http`,url:O(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},k=e=>{if(!t.existsSync(e))return{};try{return JSON.parse(t.readFileSync(e,`utf8`))}catch{return{}}},fe=e=>{let n=D(e);return t.existsSync(n)?k(n):null},pe=e=>{let r=D(e);t.mkdirSync(n.dirname(r),{recursive:!0});let i=k(r),a=i.mcpServers&&typeof i.mcpServers==`object`?{...i.mcpServers}:{};for(let t of E(e.name))delete a[t];Object.assign(a,de(e)),i.mcpServers=a,t.writeFileSync(r,JSON.stringify(i,null,2)+`
55
+ `)},me=e=>{let n=D(e);if(!t.existsSync(n))return;let r=k(n);if(r.mcpServers&&typeof r.mcpServers==`object`)for(let t of E(e.name))delete r.mcpServers[t];t.writeFileSync(n,JSON.stringify(r,null,2)+`
56
+ `)},he=e=>{let t=o();return e===t?`$HOME`:e.startsWith(t+n.sep)?`$HOME/${e.slice(t.length+1)}`:e},A=()=>{let e=he(f());return`[ -r "${e}" ] && source "${e}"`},ge=e=>{let t=A();if(e.includes(t))return e;let n=e.replace(/\n*$/,``),r=`# inscope: load each workspace's tokens (GitHub, Slack) from \$PWD on every cd\n${t}`;return n.length?`${n}\n\n${r}\n`:`${r}\n`},_e=()=>{let e=h(),n=``;try{n=t.readFileSync(e,`utf8`)}catch{}let r=ge(n);r!==n&&t.writeFileSync(e,r)},ve=()=>{try{return t.readFileSync(h(),`utf8`).includes(A())}catch{return!1}},j=e=>{let r=f();t.mkdirSync(n.dirname(r),{recursive:!0}),t.writeFileSync(r,T(e)),oe(e),_e();let i=[];for(let t of e.workspaces)pe(t),i.push(D(t));return{hook:r,gitconfig:e.workspaces.some(e=>e.git?.email||e.git?.name),mcp:i}},M=()=>({version:1,workspaces:[]}),N=()=>t.existsSync(d()),P=()=>{let e=d(),n=t.readFileSync(e,`utf8`),r=JSON.parse(n);return ye(r),r},F=e=>{let r=d();t.mkdirSync(n.dirname(r),{recursive:!0}),t.writeFileSync(r,JSON.stringify(e,null,2)+`
57
+ `)},ye=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)}},be=e=>n.basename(u(e)),xe=(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)},Se=(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}},Ce=(e,t)=>{let n=xe(e,t);return n?{cfg:{...e,workspaces:e.workspaces.filter(e=>e.name!==n.name)},removed:n}:{cfg:e}},I=(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??``}},we=()=>process.platform===`darwin`,L=()=>process.env.USER||``,Te=(e,t=I)=>{let n=t(`gh`,[`auth`,`token`,`-u`,e]),r=n.stdout.trim();return n.status===0&&r?r:null},Ee=(e=I)=>{let t=e(`gh`,[`auth`,`status`]);return(t.stdout+t.stderr).trim()},De=(e=I)=>{let t=[];for(let n of Ee(e).matchAll(/account (\S+) \(/g))t.includes(n[1])||t.push(n[1]);return t},R=(e,t=I)=>{let n=t(`git`,[`config`,`--global`,e]),r=n.stdout.trim();return n.status===0&&r?r:null},z=(e,t=I)=>{let n=t(`security`,[`find-generic-password`,`-a`,L(),`-s`,e,`-w`]);return n.status===0&&n.stdout.trim().length>0},Oe=(e,t,n=I)=>{let r=n(`security`,[`add-generic-password`,`-U`,`-a`,L(),`-s`,e,`-w`,t]);if(r.status!==0)throw Error(`security add-generic-password failed: ${r.stderr.trim()||`unknown error`}`)},B=e=>`security add-generic-password -U -a "${L()||`$USER`}" -s ${e} -w 'xoxp-...'`,ke=(e,t=I)=>{let n=t(`git`,[`config`,`--file`,e,`user.email`]);return n.status===0?n.stdout.trim():null},V=()=>!!(process.stdin.isTTY&&process.stdout.isTTY),H=e=>{let t=process.stdin;t.isTTY&&typeof t.setRawMode==`function`&&t.setRawMode(e)},U=(e,t=``)=>new Promise(n=>{let r=a.createInterface({input:process.stdin,output:process.stdout}),i=t?` [${t}]`:``;r.question(`${e}${i}: `,e=>{r.close(),n(e.trim()||t)})}),W=(e,t=!1)=>new Promise(n=>{let r=a.createInterface({input:process.stdin,output:process.stdout});r.question(`${e} [${t?`Y/n`:`y/N`}]: `,e=>{r.close();let i=e.trim().toLowerCase();n(i?i===`y`||i===`yes`:t)})}),Ae=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())})}),G=`\x1B[36m`,K=`\x1B[0m`,je=(e,t,n=0)=>new Promise(r=>{if(!V()||t.length===0){r(t[Math.min(n,t.length-1)]?.value);return}let i=Math.max(0,Math.min(n,t.length-1)),o=process.stdout;o.write(e+`
59
+ `);let s=e=>{e||o.write(`\x1b[${t.length}A`);for(let e=0;e<t.length;e++){let n=e===i,r=`${n?`โฏ`:` `} ${t[e].label}`;o.write(`\x1b[2K ${n?G+r+K:r}\n`)}};s(!0),a.emitKeypressEvents(process.stdin),H(!0),process.stdin.resume();let c=()=>{process.stdin.off(`keypress`,l),H(!1),process.stdin.pause()},l=(e,n)=>{n.name===`up`||n.name===`k`?(i=(i-1+t.length)%t.length,s(!1)):n.name===`down`||n.name===`j`?(i=(i+1)%t.length,s(!1)):n.name===`return`||n.name===`enter`?(c(),r(t[i].value)):n.ctrl&&n.name===`c`&&(c(),o.write(`
60
+ `),process.exit(130))};process.stdin.on(`keypress`,l)}),Me=(e,t)=>new Promise(n=>{let r=t.map(e=>!!e.checked),i=()=>t.filter((e,t)=>r[t]).map(e=>e.value);if(!V()||t.length===0){n(i());return}let o=0,s=process.stdout;s.write(e+`
61
+ `);let c=e=>{e||s.write(`\x1b[${t.length}A`);for(let e=0;e<t.length;e++){let n=e===o,i=`${n?`โฏ`:` `} ${r[e]?`โ—‰`:`โ—ฏ`} ${t[e].label}`;s.write(`\x1b[2K ${n?G+i+K:i}\n`)}};c(!0),a.emitKeypressEvents(process.stdin),H(!0),process.stdin.resume();let l=()=>{process.stdin.off(`keypress`,u),H(!1),process.stdin.pause()},u=(e,a)=>{a.name===`up`||a.name===`k`?(o=(o-1+t.length)%t.length,c(!1)):a.name===`down`||a.name===`j`?(o=(o+1)%t.length,c(!1)):a.name===`space`||e===` `?(r[o]=!r[o],c(!1)):a.name===`return`||a.name===`enter`?(l(),n(i())):a.ctrl&&a.name===`c`&&(l(),s.write(`
62
+ `),process.exit(130))};process.stdin.on(`keypress`,u)});var q=`inscope`,J=`0.2.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.
63
+ Runs interactively in a terminal; pass flags or -y to skip the prompts. Re-running
64
+ with the same path or label updates that workspace.
60
65
 
61
66
  Usage:
62
- $ ${q} add <path> [options]
67
+ $ ${q} add [path] [options]
63
68
 
64
69
  Options:
65
70
  --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)
71
+ --email <email> git commit email (omit to inherit your global identity)
72
+ --git-name <name> git commit author name (omit to inherit global)
68
73
  --label <name> workspace name; defaults to the directory basename
69
74
  --servers <list> comma-separated: github,linear,notion,slack
70
- (default: github,linear,notion)
75
+ (default: github)
71
76
  --slack-keychain <s> keychain service for the Slack token
72
- (default: slack-<label>-mcp-xoxp when slack is on)
77
+ (default: SLACK_MCP_XOXP_TOKEN_<LABEL> when slack is on)
73
78
  --slack-message allow the Slack MCP server to post messages
74
79
  --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
80
+ -y, --yes accept defaults, skip all prompts (non-interactive)
81
+ -h, --help Display help message`,Ne=[{label:`github`,value:`github`,checked:!0},{label:`linear`,value:`linear`,checked:!1},{label:`notion`,value:`notion`,checked:!1},{label:`slack`,value:`slack`,checked:!1}],Pe=async t=>{let{positionals:n,values:r}=e({allowPositionals:!0,options:{help:{type:`boolean`,short:`h`},yes:{type:`boolean`,short:`y`},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=V()&&!r.yes,a=n[0];if(!a)if(i)a=await U(`Workspace directory`,process.cwd());else throw Error(X);let o=r.label||be(a);i&&!r.label&&(o=await U(`Label`,o));let s=r.gh;s===void 0&&i&&(s=await je(`GitHub account for this workspace`,[...De().map(e=>({label:e,value:e})),{label:`(none)`,value:``}])||void 0);let c=r.email,u=r[`git-name`];if(i){if(c===void 0){let e=R(`user.email`);c=await U(`Git email${e?` [${e} ยท global]`:``} (enter to inherit global)`)||void 0}if(u===void 0){let e=R(`user.name`);u=await U(`Git name${e?` [${e} ยท global]`:``} (enter to inherit global)`)||void 0}}let d;d=r.servers===void 0?i?await Me(`MCP servers (space toggles, enter confirms)`,Ne):[`github`]:r.servers.split(`,`).map(e=>e.trim()).filter(Boolean);let f=d.includes(`slack`)||!!r[`slack-keychain`]||!!r[`seed-slack`],p=o.toUpperCase().replace(/[^A-Z0-9]+/g,`_`),m=r[`slack-keychain`]||`SLACK_MCP_XOXP_TOKEN_${p}`,h=!!r[`slack-message`],g=!!r[`seed-slack`];f&&i&&(r[`slack-keychain`]||(m=await U(`Slack keychain service`,m)),r[`slack-message`]||(h=await W(`Allow Slack to post messages?`,!1)),r[`seed-slack`]||(g=await W(`Store the Slack token now?`,!1)));let _={github:d.includes(`github`),linear:d.includes(`linear`),notion:d.includes(`notion`),slack:f?{keychain:m,addMessageTool:h}:!1},v=c||u?{email:c,name:u}:void 0,y={name:o,path:l(a),gh:s,git:v,servers:_},b=Se(N()?P():M(),y);if(F(b),j(b),console.log(`\nโœ“ workspace "${o}" -> ${y.path}`),console.log(`โœ“ regenerated the hook, git includes, and ${y.path}/.mcp.json`),_.slack)if(g){let e=await Ae(`Paste the Slack xoxp token for ${m}: `);e?(Oe(m,e),console.log(`โœ“ stored ${m} in the macOS keychain`)):console.error(`No token entered; skipped keychain write.`)}else z(m)||console.log(`\nSlack token not in the keychain yet. Store it once with:\n ${B(m)}`);console.log(`\nLaunch \`claude\` from ${y.path} (or relaunch) to pick up the new identity.`),process.exit(0)},Fe=`Regenerate the chpwd hook, git includes, and every .mcp.json
76
82
  from your config. Idempotent: run it any time the config changes.
77
83
 
78
84
  Usage:
79
85
  $ ${q} apply
80
86
 
81
87
  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,
88
+ -h, --help Display help message`,Ie=t=>{let{values:n}=e({allowPositionals:!0,options:{help:{type:`boolean`,short:`h`}},args:t});n.help&&(console.log(Fe),process.exit(0)),N()||(console.error(`No config found. Run \`${q} init\` first.`),process.exit(1));let r=P(),i=j(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)},Le=e=>{try{return t.readFileSync(e,`utf8`)}catch{return null}},Re=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},ze=(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)})},Be=(e=I)=>{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}},Ve=(e,n=I)=>{let r=[];we()||r.push({status:`warn`,label:`platform`,detail:`inscope's secret resolution targets macOS (gh keyring + Keychain)`});let i=f(),a=Le(i);a===null?r.push({status:`fail`,label:`hook`,detail:`missing ${i}; run \`inscope init\``}):a===T(e)?r.push({status:`ok`,label:`hook`,detail:i}):r.push({status:`warn`,label:`hook`,detail:"out of date; run `inscope apply`"}),r.push(ve()?{status:`ok`,label:`zshrc`,detail:`sources the hook`}:{status:`warn`,label:`zshrc`,detail:"does not source the hook; run `inscope init`"}),e.workspaces.some(C)&&r.push(ne(m(),S)===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(Te(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(z(t,n)?{status:`ok`,label:`${e} slack`,detail:t}:{status:`fail`,label:`${e} slack`,detail:`${t} not in keychain; run \`${B(t)}\``})}if(C(i)){let a=w(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=ke(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=fe(i);if(a===null)r.push({status:`warn`,label:`${e} mcp`,detail:"no .mcp.json; run `inscope apply`"});else{let t=E(i.name).filter(e=>a.mcpServers?.[e]);r.push({status:`ok`,label:`${e} mcp`,detail:`${t.length} server(s)`});let n=Re(a);n.length&&r.push({status:`warn`,label:`${e} mcp`,detail:`unpinned: ${n.join(`, `)}`})}}return r},He=`Verify the setup: gh tokens resolve, keychain entries exist,
83
89
  git emails match per path, the hook is current, and no MCP server is unpinned.
84
90
  Exits non-zero if any check fails.
85
91
 
@@ -87,34 +93,34 @@ Usage:
87
93
  $ ${q} doctor
88
94
 
89
95
  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
96
+ -h, --help Display help message`,Ue={ok:`โœ“`,warn:`!`,fail:`โœ—`},Z=t=>{let{values:n}=e({allowPositionals:!0,options:{help:{type:`boolean`,short:`h`}},args:t});n.help&&(console.log(He),process.exit(0)),N()||(console.error(`No config found. Run \`${q} init\` first.`),process.exit(1));let r=P(),i=Ve(r);for(let e of i)console.log(`${Ue[e.status]} ${e.label}${e.detail?` ${e.detail}`:``}`);let a=ze(r);if(a){let e=Be();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(`
97
+ All checks passed.`),process.exit(0)},We=`Set up inscope: create the config, generate the chpwd hook, and
92
98
  source it from ~/.zshrc. Safe to run again; it never overwrites your config.
93
99
 
94
100
  Usage:
95
101
  $ ${q} init
96
102
 
97
103
  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(`
104
+ -h, --help Display help message`,Ge=t=>{let{values:n}=e({allowPositionals:!0,options:{help:{type:`boolean`,short:`h`}},args:t});n.help&&(console.log(We),process.exit(0));let r;N()?(r=P(),console.log(`Using existing config at ${d()}`)):(r=M(),F(r),console.log(`Created ${d()}`)),j(r),console.log(`Generated the chpwd hook and added a source line to ~/.zshrc.`),console.log(`
99
105
  Next steps:
100
106
  1. Reload your shell: source ~/.zshrc (or open a new terminal)
101
107
  2. Sign each GitHub account in: gh auth login
102
108
  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
109
+ `),process.exit(0)},Ke=`List the configured workspaces. Run \`${q} doctor\` to verify
104
110
  that their tokens and identities actually resolve.
105
111
 
106
112
  Usage:
107
113
  $ ${q} list
108
114
 
109
115
  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
116
+ -h, --help Display help message`,qe=e=>[e.github&&`github`,e.linear&&`linear`,e.notion&&`notion`,e.slack&&`slack`].filter(Boolean).join(`, `)||`none`,Je=t=>{let{values:n}=e({allowPositionals:!0,options:{help:{type:`boolean`,short:`h`}},args:t});n.help&&(console.log(Ke),process.exit(0)),N()||(console.error(`No config found. Run \`${q} init\` first.`),process.exit(1));let r=P();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 ${qe(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
117
  servers inscope manages; leaves your keychain and gh accounts untouched.
112
118
 
113
119
  Usage:
114
120
  $ ${q} rm <path|label>
115
121
 
116
122
  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:
123
+ -h, --help Display help message`,Ye=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);N()||(console.error(`No config found. Run \`${q} init\` first.`),process.exit(1));let{cfg:a,removed:o}=Ce(P(),i);o||(console.error(`No workspace matching "${i}".`),process.exit(1)),me(o),se(o.name),F(a),j(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
124
  ${q}@${J}
119
125
 
120
126
  Per-workspace identity for Claude Code: scope MCP servers, GitHub auth, and git
@@ -136,4 +142,4 @@ Options:
136
142
  -h, --help Display help
137
143
 
138
144
  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{};
145
+ ${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 Ge(n);case`add`:return await Pe(n);case`rm`:case`remove`:return Ye(n);case`ls`:case`list`:return Je(n);case`apply`:case`sync`:return Ie(n);case`doctor`:return Z(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 CHANGED
@@ -41,8 +41,9 @@ declare const removeWorkspace: (cfg: Config, key: string) => {
41
41
  };
42
42
  //#endregion
43
43
  //#region src/apply.d.ts
44
- declare const ZSHRC_BLOCK_ID = "zshrc";
44
+ declare const renderZshrcSource: (current: string) => string;
45
45
  declare const ensureZshrcSource: () => void;
46
+ declare const zshrcSourcesHook: () => boolean;
46
47
  type ApplyResult = {
47
48
  hook: string;
48
49
  gitconfig: boolean;
@@ -63,6 +64,8 @@ declare const defaultRunner: Runner;
63
64
  declare const isMacOS: () => boolean;
64
65
  declare const ghToken: (account: string, run?: Runner) => string | null;
65
66
  declare const ghStatus: (run?: Runner) => string;
67
+ declare const ghAccounts: (run?: Runner) => string[];
68
+ declare const gitGlobal: (key: string, run?: Runner) => string | null;
66
69
  declare const keychainHas: (service: string, run?: Runner) => boolean;
67
70
  declare const keychainSet: (service: string, token: string, run?: Runner) => void;
68
71
  declare const keychainSetCommand: (service: string) => string;
@@ -104,4 +107,4 @@ declare const readMcp: (ws: Workspace) => Record<string, any> | null;
104
107
  declare const applyMcp: (ws: Workspace) => void;
105
108
  declare const removeMcp: (ws: Workspace) => void;
106
109
  //#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 };
110
+ export { ApplyResult, CONFIG_VERSION, Check, CheckStatus, Config, HttpServer, RunResult, Runner, SLACK_MCP_VERSION, Servers, SlackServer, Workspace, applyAll, applyGitconfig, applyMcp, configExists, currentWorkspace, defaultConfig, defaultRunner, ensureZshrcSource, findWorkspace, ghAccounts, ghStatus, ghToken, gitEmailForFile, gitGlobal, isMacOS, keychainHas, keychainSet, keychainSetCommand, labelFromPath, liveSnapshot, loadConfig, managedKeys, mcpFilePath, readMcp, removeMcp, removeWorkspace, renderGitInclude, renderHook, renderMcp, renderPerWorkspaceGitconfig, renderServers, renderZshrcSource, runDoctor, saveConfig, upsertWorkspace, validateConfig, zshrcSourcesHook };
package/dist/index.mjs CHANGED
@@ -1,11 +1,11 @@
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,`
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,m=()=>({version:1,workspaces:[]}),te=()=>e.existsSync(l()),ne=()=>{let t=l(),n=e.readFileSync(t,`utf8`),r=JSON.parse(n);return h(r),r},re=n=>{let r=l();e.mkdirSync(t.dirname(r),{recursive:!0}),e.writeFileSync(r,JSON.stringify(n,null,2)+`
2
+ `)},h=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)}},ie=e=>t.basename(c(e)),g=(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)},ae=(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}},_=(e,t)=>{let n=g(e,t);return n?{cfg:{...e,workspaces:e.workspaces.filter(e=>e.name!==n.name)},removed:n}:{cfg:e}},v=e=>`# >>> inscope:${e} >>>`,y=e=>`# <<< inscope:${e} <<<`,b=e=>e.replace(/[.*+?^${}()|[\]\\]/g,`\\$&`),x=e=>RegExp(`${b(v(e))}\\n[\\s\\S]*?\\n${b(y(e))}\\n?`),S=(e,t)=>{let n=t.replace(/\n+$/,``);return`${v(e)}\n${n}\n${y(e)}\n`},C=t=>{try{return e.readFileSync(t,`utf8`)}catch{return``}},oe=(n,r,i)=>{e.mkdirSync(t.dirname(n),{recursive:!0});let a=C(n),o=S(r,i),s=x(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)},se=(t,n)=>{let r=C(t);if(!r)return;let i=r.replace(x(n),``).replace(/\n{3,}/g,`
3
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(`
4
+ `).replace(/^\n+/,``);e.writeFileSync(t,i)},ce=(e,t)=>{let n=C(e).match(RegExp(`${b(v(t))}\\n([\\s\\S]*?)\\n${b(y(t))}`));return n?n[1]:null},w=`gitconfig`,T=e=>!!(e.git&&(e.git.email||e.git.name)),E=e=>t.join(d(),`${e}.gitconfig`),D=e=>s(e).replace(/\/+$/,``)+`/`,O=e=>e.workspaces.filter(T).map(e=>`[includeIf "gitdir:${D(e.path)}"]\n\tpath = ${s(E(e.name))}`).join(`
5
+ `),k=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
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
7
+ `},A=t=>{e.mkdirSync(d(),{recursive:!0});for(let n of t.workspaces)T(n)&&e.writeFileSync(E(n.name),k(n));let n=O(t);n?oe(f(),w,n):se(f(),w)},le=e=>{let t=s(e);return t===`~`?`"$HOME/"*`:t.startsWith(`~/`)?`"$HOME/${t.slice(2)}/"*`:`"${t}/"*`},ue=e=>e.servers.slack?e.servers.slack.keychain:``,j=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/inscope/inscope.json
9
9
  # Edit there, then run \`inscope apply\` to regenerate this file.
10
10
  #
11
11
  # One chpwd hook resolves per-workspace secrets from \$PWD on every cd: it maps
@@ -15,7 +15,7 @@ import e from"node:fs";import t from"node:path";import n from"node:os";import{sp
15
15
  __inscope_resolve_identity() {
16
16
  local ws
17
17
  case "\${PWD}/" in
18
- ${t.map(e=>` ${se(e.path)}) ws=${e.name} ;;`).join(`
18
+ ${t.map(e=>` ${le(e.path)}) ws=${e.name} ;;`).join(`
19
19
  `)||` # no workspaces configured`}
20
20
  *) ws="" ;;
21
21
  esac
@@ -25,7 +25,7 @@ ${t.map(e=>` ${se(e.path)}) ws=${e.name} ;;`).join(`
25
25
 
26
26
  local gh_user="" slack_svc=""
27
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(`
28
+ ${t.map(e=>{let t=[];e.gh&&t.push(`gh_user=${e.gh}`);let n=ue(e);return n&&t.push(`slack_svc=${n}`),` ${e.name}) ${t.length?t.join(`; `):`:`} ;;`}).join(`
29
29
  `)||` # no workspaces configured`}
30
30
  *) return ;; # outside a mapped workspace: nothing set
31
31
  esac
@@ -51,6 +51,6 @@ autoload -Uz add-zsh-hook
51
51
  add-zsh-hook chpwd __inscope_resolve_identity
52
52
  __inscope_ws="__init__" # force the first resolve, clearing any inherited token
53
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};
54
+ `},M=`1.3.0`,de=[`github`,`linear`,`notion`,`slack`],N=e=>de.map(t=>`${t}-${e}`),P=e=>t.join(c(e.path),`.mcp.json`),F=(e,t)=>e&&typeof e==`object`&&e.url?e.url:t,I=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:F(t.linear,`https://mcp.linear.app/mcp`)}),t.notion&&(n[`notion-${e.name}`]={type:`http`,url:F(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@${M}`,`--transport`,`stdio`],env:r}}return n},L=e=>({mcpServers:I(e)}),R=t=>{if(!e.existsSync(t))return{};try{return JSON.parse(e.readFileSync(t,`utf8`))}catch{return{}}},z=t=>{let n=P(t);return e.existsSync(n)?R(n):null},B=n=>{let r=P(n);e.mkdirSync(t.dirname(r),{recursive:!0});let i=R(r),a=i.mcpServers&&typeof i.mcpServers==`object`?{...i.mcpServers}:{};for(let e of N(n.name))delete a[e];Object.assign(a,I(n)),i.mcpServers=a,e.writeFileSync(r,JSON.stringify(i,null,2)+`
55
+ `)},V=t=>{let n=P(t);if(!e.existsSync(n))return;let r=R(n);if(r.mcpServers&&typeof r.mcpServers==`object`)for(let e of N(t.name))delete r.mcpServers[e];e.writeFileSync(n,JSON.stringify(r,null,2)+`
56
+ `)},fe=e=>{let n=i();return e===n?`$HOME`:e.startsWith(n+t.sep)?`$HOME/${e.slice(n.length+1)}`:e},H=()=>{let e=fe(u());return`[ -r "${e}" ] && source "${e}"`},U=e=>{let t=H();if(e.includes(t))return e;let n=e.replace(/\n*$/,``),r=`# inscope: load each workspace's tokens (GitHub, Slack) from \$PWD on every cd\n${t}`;return n.length?`${n}\n\n${r}\n`:`${r}\n`},W=()=>{let t=p(),n=``;try{n=e.readFileSync(t,`utf8`)}catch{}let r=U(n);r!==n&&e.writeFileSync(t,r)},G=()=>{try{return e.readFileSync(p(),`utf8`).includes(H())}catch{return!1}},pe=n=>{let r=u();e.mkdirSync(t.dirname(r),{recursive:!0}),e.writeFileSync(r,j(n)),A(n),W();let i=[];for(let e of n.workspaces)B(e),i.push(P(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},X=(e=K)=>{let t=e(`gh`,[`auth`,`status`]);return(t.stdout+t.stderr).trim()},me=(e=K)=>{let t=[];for(let n of X(e).matchAll(/account (\S+) \(/g))t.includes(n[1])||t.push(n[1]);return t},he=(e,t=K)=>{let n=t(`git`,[`config`,`--global`,e]),r=n.stdout.trim();return n.status===0&&r?r:null},Z=(e,t=K)=>{let n=t(`security`,[`find-generic-password`,`-a`,J(),`-s`,e,`-w`]);return n.status===0&&n.stdout.trim().length>0},ge=(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`}`)},Q=e=>`security add-generic-password -U -a "${J()||`$USER`}" -s ${e} -w 'xoxp-...'`,$=(e,t=K)=>{let n=t(`git`,[`config`,`--file`,e,`user.email`]);return n.status===0?n.stdout.trim():null},_e=t=>{try{return e.readFileSync(t,`utf8`)}catch{return null}},ve=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},ye=(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)})},be=(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}},xe=(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=_e(i);a===null?r.push({status:`fail`,label:`hook`,detail:`missing ${i}; run \`inscope init\``}):a===j(t)?r.push({status:`ok`,label:`hook`,detail:i}):r.push({status:`warn`,label:`hook`,detail:"out of date; run `inscope apply`"}),r.push(G()?{status:`ok`,label:`zshrc`,detail:`sources the hook`}:{status:`warn`,label:`zshrc`,detail:"does not source the hook; run `inscope init`"}),t.workspaces.some(T)&&r.push(ce(f(),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 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(Z(e,n)?{status:`ok`,label:`${t} slack`,detail:e}:{status:`fail`,label:`${t} slack`,detail:`${e} not in keychain; run \`${Q(e)}\``})}if(T(i)){let a=E(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=$(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=z(i);if(a===null)r.push({status:`warn`,label:`${t} mcp`,detail:"no .mcp.json; run `inscope apply`"});else{let e=N(i.name).filter(e=>a.mcpServers?.[e]);r.push({status:`ok`,label:`${t} mcp`,detail:`${e.length} server(s)`});let n=ve(a);n.length&&r.push({status:`warn`,label:`${t} mcp`,detail:`unpinned: ${n.join(`, `)}`})}}return r};export{ee as CONFIG_VERSION,M as SLACK_MCP_VERSION,pe as applyAll,A as applyGitconfig,B as applyMcp,te as configExists,ye as currentWorkspace,m as defaultConfig,K as defaultRunner,W as ensureZshrcSource,g as findWorkspace,me as ghAccounts,X as ghStatus,Y as ghToken,$ as gitEmailForFile,he as gitGlobal,q as isMacOS,Z as keychainHas,ge as keychainSet,Q as keychainSetCommand,ie as labelFromPath,be as liveSnapshot,ne as loadConfig,N as managedKeys,P as mcpFilePath,z as readMcp,V as removeMcp,_ as removeWorkspace,O as renderGitInclude,j as renderHook,L as renderMcp,k as renderPerWorkspaceGitconfig,I as renderServers,U as renderZshrcSource,xe as runDoctor,re as saveConfig,ae as upsertWorkspace,h as validateConfig,G as zshrcSourcesHook};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "inscope",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Per-workspace identity for Claude Code: scope MCP servers, GitHub auth, and git commit identity to the directory you are in.",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -29,45 +29,6 @@
29
29
  "files": [
30
30
  "dist"
31
31
  ],
32
- "scripts": {
33
- "bin": "tsdown && node dist/bin/index.mjs",
34
- "build": "tsdown",
35
- "dev": "tsdown --watch",
36
- "test": "bun test",
37
- "typecheck": "tsc --noEmit",
38
- "prepare": "npx simple-git-hooks"
39
- },
40
- "simple-git-hooks": {
41
- "pre-commit": "npx lint-staged",
42
- "commit-msg": "npx commitlint --edit $1"
43
- },
44
- "commitlint": {
45
- "extends": [
46
- "@commitlint/config-conventional"
47
- ]
48
- },
49
- "lint-staged": {
50
- "*": "prettier --write --ignore-unknown",
51
- "package.json": "sort-package-json"
52
- },
53
- "prettier": {
54
- "plugins": [
55
- "@ianvs/prettier-plugin-sort-imports"
56
- ],
57
- "semi": false
58
- },
59
- "devDependencies": {
60
- "@commitlint/cli": "^19.8.1",
61
- "@commitlint/config-conventional": "^19.8.1",
62
- "@ianvs/prettier-plugin-sort-imports": "^4.4.2",
63
- "@types/node": "^22.15.32",
64
- "lint-staged": "^15.5.2",
65
- "prettier": "^3.5.3",
66
- "simple-git-hooks": "^2.13.0",
67
- "sort-package-json": "^3.2.1",
68
- "tsdown": "^0.16.4",
69
- "typescript": "^5.8.3"
70
- },
71
32
  "bin": {
72
33
  "inscope": "dist/bin/index.mjs"
73
34
  }