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 +189 -42
- package/dist/bin/index.mjs +33 -27
- package/dist/index.d.mts +5 -2
- package/dist/index.mjs +11 -11
- package/package.json +1 -40
package/README.md
CHANGED
|
@@ -1,41 +1,107 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Inscope
|
|
2
2
|
|
|
3
|
-
Per-workspace identity for [Claude Code](https://claude.com/claude-code)
|
|
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
|
-
|
|
9
|
-
|
|
5
|
+
[](https://twitter.com/nrjdalal_dev)
|
|
6
|
+
[](https://www.npmjs.com/package/inscope)
|
|
7
|
+
[](https://www.npmjs.com/package/inscope)
|
|
8
|
+
[](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
|
-
|
|
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
|
-
|
|
19
|
-
> [Race-Free Identity in Claude Code](https://zerostarter.dev/blog/mcp-per-workspace).
|
|
83
|
+
## ๐งฐ Requirements
|
|
20
84
|
|
|
21
|
-
|
|
85
|
+
macOS, zsh, [`gh`](https://cli.github.com), and [Claude Code](https://claude.com/claude-code).
|
|
22
86
|
|
|
23
|
-
|
|
87
|
+
---
|
|
24
88
|
|
|
25
|
-
##
|
|
89
|
+
## ๐ Quick Usage
|
|
26
90
|
|
|
27
|
-
|
|
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
|
-
|
|
97
|
+
Then walk through the setup once:
|
|
32
98
|
|
|
33
|
-
```
|
|
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
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
package/dist/bin/index.mjs
CHANGED
|
@@ -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``}},
|
|
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)},
|
|
5
|
-
`),
|
|
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
|
-
`},
|
|
8
|
-
# Source of truth: ~/.config/
|
|
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=>` ${
|
|
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=
|
|
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
|
-
`},
|
|
55
|
-
`)},
|
|
56
|
-
`)},
|
|
57
|
-
`)},
|
|
58
|
-
`),n.close(),t(e.trim())})})
|
|
59
|
-
|
|
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
|
|
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
|
|
67
|
-
--git-name <name> git commit author name (
|
|
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
|
|
75
|
+
(default: github)
|
|
71
76
|
--slack-keychain <s> keychain service for the Slack token
|
|
72
|
-
(default:
|
|
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
|
-
-
|
|
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`,
|
|
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`,
|
|
91
|
-
All checks passed.`),process.exit(0)},
|
|
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`,
|
|
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)},
|
|
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`,
|
|
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`,
|
|
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
|
|
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
|
|
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,
|
|
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,
|
|
2
|
-
`)},
|
|
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)},
|
|
5
|
-
`),
|
|
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
|
-
`},
|
|
8
|
-
# Source of truth: ~/.config/
|
|
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=>` ${
|
|
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=
|
|
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
|
-
`},
|
|
55
|
-
`)},
|
|
56
|
-
`)},
|
|
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.
|
|
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
|
}
|