SLOPSHOPPER

credential-paster

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

paneguardcommandtoastprompttool
★ 2v0.1.1MITupdated 2026-10-05timarnoldev/credential-paster
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · credential-paster
│ ┃ paste-secret ✕ › fix the failing auth test and add an audit log call │ ┃ No secret requested. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /paste-secret │ ⎿ credential-paster: No secret requested. Usage: /paste-secret <fi │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · paste-secret
No secret requested.
README

credential-paster

Give your AI coding agent API keys – without giving them to the AI.

skills.sh Claude Code plugin macOS | Linux License: MIT

<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">

Never in the chat

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">

Any file format

.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">

Paste field in Claude Code

A masked input right above the prompt. Paste, Enter, done. Also: native password dialog, clipboard, hidden terminal prompt, password managers.

</td> <td valign="top">

Works with every agent

Claude Code, Codex, Cursor, Gemini CLI, Copilot, OpenCode and anything else that reads Agent Skills. Bash + awk only.

</td> </tr> </table>

Install

AgentCommand
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 PATHgit 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 in npx 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

How it works

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

Input methods

MethodHow you pastemacOSLinux
Paste fieldmasked field inside Claude Codeyesyes
Dialognative password dialog pops upbuilt-inzenity / kdialog
Clipboardcopy, say "ok" – cleared afterwardsbuilt-inwl-paste / xclip / xsel
Terminalhidden prompt in your own terminalyesyes
Password manager`op read … \paste-secret --stdin …`yesyes

CLI

# .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
FileThe agent writesEscape
config.json"token": "__PASTE_SECRET_TOKEN__"json (auto)
YAML / TOMLtoken: "__PASTE_SECRET_TOKEN__"json
.npmrc//registry.npmjs.org/:_authToken=__PASTE_SECRET_NPM__none
.netrcmachine api.x.com login me password __PASTE_SECRET_PW__none
connection URLpostgres://user:REDACTED@host/dburl
shell scriptTOKEN=__PASTE_SECRET_TOKEN__shell
PEM / multi-line__PASTE_SECRET_KEY__ on its own linenone

auto picks json for *.json, dotenv for .env*, otherwise none. See paste-secret --help.

Security

  • The value reaches awk via the environment, never via argv – invisible in ps.
  • The script never prints the value; output is file, key and status only.
  • Clipboard mode is convenient, but clipboard managers with history may keep a copy.
  • The Claude Code paste field is built on the plugin hooks API (early access); without an interactive UI the agent falls back to the other methods.
  • For a hard guarantee that Claude never reads secret files, add to ~/.claude/settings.json: ``json { "permissions": { "deny": ["Read(/.env)", "Read(/.env.*)"] } } ``

Why

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.

Development

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 Linux
Source 2 files
hooks/register.tsx 261 lines
1import { 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}
261
types/index.d.ts 17 lines
1export 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