Put API keys and other secrets into any config file without them appearing in chat, shell history or tool output.

Give your AI coding agent API keys – without giving them to the AI.
<img src="docs/demo.svg" alt="Claude Code asks for OPENAI_API_KEY, a masked paste field opens, the key is written to .env without entering the chat" width="760">
When an agent needs a credential, the usual answer is to paste it into the chat. From then on it lives in the transcript, the model's context and your logs.
credential-paster splits the job: the agent decides where a secret goes, you supply the value through a channel the agent cannot read, and a tiny script writes it straight into the file.
<table> <tr> <td width="50%" valign="top">
The value doesn't show up in the transcript, the model's context, shell history, ps or tool output. The agent only ever sees SET / MISSING.
</td> <td width="50%" valign="top">
.env out of the box. JSON, YAML, TOML, .npmrc, .netrc, connection URLs, PEM keys via placeholders, with the right escaping.
</td> </tr> <tr> <td valign="top">
A masked input right above the prompt. Paste, Enter, done. Also: native password dialog, clipboard, hidden terminal prompt, password managers.
</td> <td valign="top">
Claude Code, Codex, Cursor, Gemini CLI, Copilot, OpenCode and anything else that reads Agent Skills. Bash + awk only.
</td> </tr> </table>
| Agent | Command |
|---|---|
| Claude Code – recommended, includes the paste field | /plugin marketplace add timarnoldev/credential-paster<br>/plugin install credential-paster@credential-paster |
| Codex, Cursor, Gemini CLI, Copilot, OpenCode, … | npx skills add timarnoldev/credential-paster |
Manual – also puts paste-secret on your PATH | git clone https://github.com/timarnoldev/credential-paster && ./credential-paster/install.sh |
[!TIP] After installing, run
/reload-plugins(or restart). The paste field is available from your next message on. Using the plugin? Don't also select Claude Code innpx skills add, otherwise the skill is installed twice.
Optionally pick a default input method so the agent doesn't ask:
export PASTE_SECRET_METHOD=dialog # dialog | clipboard | tty | stdin
sequenceDiagram
actor You
participant Agent
participant PS as paste-secret
participant File as .env / config
Agent->>PS: request OPENAI_API_KEY for .env
PS->>You: masked field / password dialog
You-->>PS: paste secret
PS->>File: write value (escaped, mode 600)
PS-->>Agent: "OK: OPENAI_API_KEY set in .env"
Note over Agent: never sees the value,<br/>uses it by reference only
Just ask for what you need – "hook up the OpenAI client", "add my npm token" – the skill kicks in when a credential is missing. You can also open the field yourself in Claude Code:
/paste-secret .env OPENAI_API_KEY
| Method | How you paste | macOS | Linux | |
|---|---|---|---|---|
| Paste field | masked field inside Claude Code | yes | yes | |
| Dialog | native password dialog pops up | built-in | zenity / kdialog | |
| Clipboard | copy, say "ok" – cleared afterwards | built-in | wl-paste / xclip / xsel | |
| Terminal | hidden prompt in your own terminal | yes | yes | |
| Password manager | `op read … \ | paste-secret --stdin …` | yes | yes |
# .env files: set KEY (file created if missing, mode 600)
paste-secret .env OPENAI_API_KEY # hidden terminal prompt
paste-secret --dialog .env OPENAI_API_KEY # native password dialog
paste-secret --clipboard .env OPENAI_API_KEY # from clipboard, then clears it
op read "op://Private/OpenAI/key" | paste-secret --stdin .env OPENAI_API_KEY
# any format: replace a placeholder the agent put into the file
paste-secret --dialog --placeholder __PASTE_SECRET_TOKEN__ config.json # JSON-escaped (auto)
paste-secret --dialog --placeholder __PASTE_SECRET_DB__ --escape url compose.yml # percent-encoded
# status without revealing anything
paste-secret --check .env OPENAI_API_KEY # SET | EMPTY | MISSING
paste-secret --check --placeholder __PASTE_SECRET_TOKEN__ config.json # DONE | PENDING
paste-secret --info # preferred + available methods
| File | The agent writes | Escape |
|---|---|---|
config.json | "token": "__PASTE_SECRET_TOKEN__" | json (auto) |
| YAML / TOML | token: "__PASTE_SECRET_TOKEN__" | json |
.npmrc | //registry.npmjs.org/:_authToken=__PASTE_SECRET_NPM__ | none |
.netrc | machine api.x.com login me password __PASTE_SECRET_PW__ | none |
| connection URL | postgres://user:REDACTED@host/db | url |
| shell script | TOKEN=__PASTE_SECRET_TOKEN__ | shell |
| PEM / multi-line | __PASTE_SECRET_KEY__ on its own line | none |
auto picks json for *.json, dotenv for .env*, otherwise none. See paste-secret --help.
awk via the environment, never via argv – invisible in ps.~/.claude/settings.json: ``json { "permissions": { "deny": ["Read(/.env)", "Read(/.env.*)"] } } ``A masked input for secrets was requested for Claude Code and closed as not planned. credential-paster fills that gap – for Claude Code and every other agent.
claude plugin test . # Claude Code plugin
tests/test.sh # CLI on macOS
docker run --rm -v "$PWD":/w -w /w debian:stable-slim bash tests/test.sh # CLI on Linuxhooks/register.tsx 261 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { CredentialPasterRequest } from '../types'
5
6// A hidden paste field inside Claude Code. The model asks for a secret with the
7// `request_secret` tool; the person pastes it into a masked field in a pane;
8// the value goes straight to the paste-secret script via stdin and never into
9// the transcript, the model's context or this plugin's state.
10
11const PANE = 'paste-secret'
12const TOOL = 'request_secret'
13const BULLET = '•'
14const ESCAPES = ['auto', 'none', 'json', 'yaml', 'toml', 'dotenv', 'shell', 'url']
15const KEY_RE = /^[A-Za-z_][A-Za-z0-9_.]*$/
16const INPUT_SURFACES = ['terminal', 'desktop', 'vscode']
17
18const request = atom({ plugin: 'credential-paster', key: 'request' } as const, null)
19const typed = atom({ plugin: 'credential-paster', key: 'typed' } as const, 0)
20const error = atom({ plugin: 'credential-paster', key: 'error' } as const, null)
21
22// The secret lives only in this module's memory until it is written.
23let secret = ''
24
25// The field shows bullets; each change is diffed against them so the real
26// characters accumulate in `secret` (one contiguous edit per change).
27const applyEdit = (shown: string) => {
28 if (!shown.includes(BULLET)) {
29 secret = shown
30 return
31 }
32 let lead = 0
33 while (lead < shown.length && shown[lead] === BULLET) lead++
34 let trail = 0
35 while (trail < shown.length - lead && shown[shown.length - 1 - trail] === BULLET) trail++
36 const inserted = shown.slice(lead, shown.length - trail)
37 const keepTrail = Math.min(trail, Math.max(0, secret.length - lead))
38 secret = secret.slice(0, lead) + inserted + secret.slice(secret.length - keepTrail)
39}
40
41const describe = (r: CredentialPasterRequest) =>
42 r.key ? `${r.key} in ${r.file}` : `secret for ${r.file}`
43
44const scriptPath = ($: EngineInterface) =>
45 `${$.plugin.root}/skills/credential-paster/scripts/paste-secret`
46
47const scriptArgs = (r: CredentialPasterRequest) =>
48 r.placeholder
49 ? ['--placeholder', r.placeholder, '--escape', r.escape ?? 'auto', r.file]
50 : [r.file, r.key ?? '']
51
52const reset = async ($: EngineInterface) => {
53 secret = ''
54 await update($, request, () => null)
55 await update($, typed, () => 0)
56 await update($, error, () => null)
57}
58
59const openPane = ($: EngineInterface) =>
60 $.ui.open({ id: PANE, title: 'Paste secret', focus: true, closeOnEscape: true, rows: 6 })
61
62const cancel = async ($: EngineInterface, notify: boolean) => {
63 const r = await read($, request)
64 await reset($)
65 if (r && notify) {
66 void $.prompt.submit({
67 text: `[credential-paster] The user cancelled entering ${describe(r)}. Nothing was written.`,
68 })
69 }
70}
71
72const save = async ($: EngineInterface) => {
73 const r = await read($, request)
74 if (!r) return
75 if (secret.trim() === '') {
76 await update($, error, () => 'Nothing pasted yet.')
77 return
78 }
79 const value = secret
80 secret = ''
81 await update($, typed, () => 0)
82 const ran = await $.process.run(['bash', scriptPath($), '--stdin', ...scriptArgs(r)], {
83 stdin: value,
84 })
85 if (ran.exitCode !== 0) {
86 await update($, error, () => (ran.stderr.trim() || 'paste-secret failed.') + ' Paste again.')
87 return
88 }
89 await reset($)
90 await $.ui.close({ id: PANE })
91 $.ui.toast(`Saved ${describe(r)}`)
92 void $.prompt.submit({
93 text: `[credential-paster] ${ran.stdout.trim()} The user supplied the secret; continue the task. Never read or print it.`,
94 })
95}
96
97const parseRequest = (input: Record<string, unknown>): CredentialPasterRequest | string => {
98 const str = (k: string) => (typeof input[k] === 'string' ? (input[k] as string) : undefined)
99 const file = str('file')
100 const key = str('key')
101 const placeholder = str('placeholder')
102 const escape = str('escape')
103 if (!file) return '`file` is required.'
104 if (!key === !placeholder) return 'Give exactly one of `key` (dotenv) or `placeholder`.'
105 if (key && !KEY_RE.test(key)) return `Invalid key name '${key}'.`
106 if (placeholder && placeholder.length < 4) return 'Placeholder must be at least 4 characters.'
107 if (escape && !ESCAPES.includes(escape)) return `Unknown escape '${escape}'.`
108 return { file, key, placeholder, escape }
109}
110
111const start = async ($: EngineInterface, r: CredentialPasterRequest): Promise<string | null> => {
112 const surfaces = await $.session.surfaces()
113 if (!surfaces.some(s => INPUT_SURFACES.includes(s))) {
114 return 'No interactive Claude Code UI is attached. Use the paste-secret script from the credential-paster skill instead.'
115 }
116 if (r.placeholder) {
117 const check = await $.process.run(['bash', scriptPath($), '--check', ...scriptArgs(r)])
118 if (!check.stdout.startsWith('PENDING')) {
119 return `Placeholder not found: ${check.stdout.trim()} Write the placeholder into the file first.`
120 }
121 }
122 secret = ''
123 await update($, request, () => r)
124 await update($, typed, () => 0)
125 await update($, error, () => null)
126 await openPane($)
127 return null
128}
129
130// Tool and command are declared at session start. A plugin installed or
131// reloaded into a running session gets no session.start, so the next prompt
132// declares them instead. Module variables reset on reload, so this re-runs.
133let isRegistered = false
134
135const ensureRegistered = async ($: EngineInterface) => {
136 if (isRegistered) return
137 try {
138 await $.tool.register({
139 name: TOOL,
140 description:
141 'Ask the user for a secret (API key, token, password) through a masked paste field in the Claude Code UI and write it into a file. You never see the value. ' +
142 'Dotenv mode: give `file` and `key` (sets KEY=value, creates the file). ' +
143 'Any other format: first write a unique placeholder such as __PASTE_SECRET_TOKEN__ into the file, then give `file`, `placeholder` and `escape` ' +
144 '(json for JSON/YAML/TOML double-quoted strings, url for URLs, shell, dotenv, none; default auto by file extension). ' +
145 'Returns immediately: then END YOUR TURN and tell the user to paste the secret into the field. A message arrives once it is saved or cancelled.',
146 inputSchema: {
147 type: 'object',
148 properties: {
149 file: { type: 'string', description: 'Target file, relative to the working directory or absolute.' },
150 key: { type: 'string', description: 'Dotenv mode: variable name, e.g. OPENAI_API_KEY.' },
151 placeholder: { type: 'string', description: 'Placeholder mode: marker already present in the file.' },
152 escape: { type: 'string', enum: ESCAPES, description: 'Placeholder mode: how to escape the value.' },
153 },
154 required: ['file'],
155 },
156 })
157 await $.command.register({
158 name: 'paste-secret',
159 description: 'Paste a secret into a file through a hidden field (reopens a pending request)',
160 argumentHint: '[<file> <KEY>]',
161 immediate: true,
162 })
163 isRegistered = true
164 } catch {
165 // Session not bound yet: try again at the next event.
166 }
167}
168
169export const register: Register = on => {
170 on('session.start', async ($, e, next) => {
171 await ensureRegistered($)
172 return next(e)
173 })
174
175 on('prompt.submit', async ($, e, next) => {
176 await ensureRegistered($)
177 return next(e)
178 })
179
180 on('tool.call', { tool: 'mcp__credential-paster__request_secret' }, async ($, e) => {
181 const r = parseRequest(e as unknown as Record<string, unknown>)
182 if (typeof r === 'string') return { deny: r }
183 const failed = await start($, r)
184 if (failed) return { deny: failed }
185 return {
186 result:
187 `A masked paste field for ${describe(r)} is open in the user's Claude Code UI. ` +
188 'End your turn now and ask the user to paste the secret there and press Enter (they can type /paste-secret to reopen the field). ' +
189 'You will get a message when it is saved.',
190 }
191 })
192
193 on('command.run', { command: 'paste-secret' }, async ($, e) => {
194 const [file, key] = e.args.trim().split(/\s+/).filter(Boolean)
195 if (file) {
196 const r = parseRequest({ file, key })
197 if (typeof r === 'string') return { text: `paste-secret: ${r} Usage: /paste-secret <file> <KEY>` }
198 const failed = await start($, r)
199 return { text: failed ?? `Paste field open for ${describe(r)}.` }
200 }
201 const r = await read($, request)
202 if (!r) return { text: 'No secret requested. Usage: /paste-secret <file> <KEY>' }
203 await openPane($)
204 return { text: `Paste field open for ${describe(r)}.` }
205 })
206
207 on('ui.close', { id: PANE }, async ($, e, next) => {
208 // Escape or the close mark: treat as cancel. Our own close after saving
209 // finds no request left and stays quiet.
210 if (e.origin.kind === 'person') await cancel($, true)
211 return next(e)
212 })
213
214 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
215 if (e.surface === 'mobile') {
216 const { Text } = $.ui.resolve(e)
217 return <Text>Paste the secret on your computer: the mobile app has no input field.</Text>
218 }
219 const { Box, Text, Input, Button } = $.ui.resolve(e)
220 const r = await read($, request)
221 const count = await read($, typed)
222 const problem = await read($, error)
223
224 if (!r) {
225 return <Text dimColor>No secret requested.</Text>
226 }
227
228 return (
229 <Box flexDirection="column">
230 <Text>
231 Paste <Text bold>{describe(r)}</Text>
232 <Text dimColor> · hidden from Claude, written directly to the file</Text>
233 </Text>
234 <Input
235 key="secret"
236 label="Secret"
237 placeholder="paste here, then Enter"
238 value={BULLET.repeat(count)}
239 submitLabel="save"
240 autoFocus
241 onInput={value => {
242 applyEdit(value)
243 void update($, typed, () => secret.length)
244 void update($, error, () => null)
245 }}
246 onSubmit={value => {
247 applyEdit(value)
248 void save($)
249 }}
250 />
251 <Box>
252 <Button key="save" label="Save" variant="primary" onPress={() => void save($)} />
253 <Text> </Text>
254 <Button key="cancel" label="Cancel" role="dismiss" onPress={async () => { await cancel($, true); await $.ui.close({ id: PANE }) }} />
255 </Box>
256 {problem && <Text color="red">{problem}</Text>}
257 </Box>
258 )
259 })
260}
261types/index.d.ts 17 lines1export type CredentialPasterRequest = {
2 file: string
3 key?: string
4 placeholder?: string
5 escape?: string
6}
7
8declare module 'claude-code' {
9 interface PluginState {
10 'credential-paster': {
11 request: CredentialPasterRequest | null
12 typed: number
13 error: string | null
14 }
15 }
16}
17