secretctl-cli 0.1.0__py3-none-any.whl

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.
secretctl/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """secretctl - Cross-platform secret storage CLI for AI agents."""
2
+
3
+ __version__ = "0.1.0"
secretctl/backend.py ADDED
@@ -0,0 +1,176 @@
1
+ """Backend implementations for secret storage."""
2
+
3
+ import platform
4
+ import subprocess
5
+ from abc import ABC, abstractmethod
6
+
7
+
8
+ class SecretBackend(ABC):
9
+ """Abstract base class for secret storage backends."""
10
+
11
+ def __init__(self, account: str):
12
+ self.account = account
13
+
14
+ @abstractmethod
15
+ def get(self, name: str) -> str | None:
16
+ """Get a secret value."""
17
+ pass
18
+
19
+ @abstractmethod
20
+ def set(self, name: str, value: str) -> None:
21
+ """Set a secret value."""
22
+ pass
23
+
24
+ @abstractmethod
25
+ def delete(self, name: str) -> None:
26
+ """Delete a secret."""
27
+ pass
28
+
29
+ @abstractmethod
30
+ def list(self) -> list[str]:
31
+ """List all secret names."""
32
+ pass
33
+
34
+
35
+ class MacOSKeychain(SecretBackend):
36
+ """macOS Keychain backend using security command."""
37
+
38
+ def get(self, name: str) -> str | None:
39
+ """Get a secret from macOS Keychain."""
40
+ try:
41
+ result = subprocess.run(
42
+ [
43
+ "security",
44
+ "find-generic-password",
45
+ "-a", self.account,
46
+ "-s", name,
47
+ "-w",
48
+ ],
49
+ capture_output=True,
50
+ text=True,
51
+ )
52
+ if result.returncode == 0:
53
+ return result.stdout.strip()
54
+ return None
55
+ except Exception:
56
+ return None
57
+
58
+ def set(self, name: str, value: str) -> None:
59
+ """Set a secret in macOS Keychain."""
60
+ # Delete existing first (security doesn't update in place)
61
+ subprocess.run(
62
+ [
63
+ "security",
64
+ "delete-generic-password",
65
+ "-a", self.account,
66
+ "-s", name,
67
+ ],
68
+ capture_output=True,
69
+ )
70
+ # Add new
71
+ result = subprocess.run(
72
+ [
73
+ "security",
74
+ "add-generic-password",
75
+ "-a", self.account,
76
+ "-s", name,
77
+ "-w", value,
78
+ "-U", # Update if exists
79
+ ],
80
+ capture_output=True,
81
+ text=True,
82
+ )
83
+ if result.returncode != 0:
84
+ raise RuntimeError(f"Failed to store secret: {result.stderr}")
85
+
86
+ def delete(self, name: str) -> None:
87
+ """Delete a secret from macOS Keychain."""
88
+ result = subprocess.run(
89
+ [
90
+ "security",
91
+ "delete-generic-password",
92
+ "-a", self.account,
93
+ "-s", name,
94
+ ],
95
+ capture_output=True,
96
+ text=True,
97
+ )
98
+ if result.returncode != 0 and "could not be found" not in result.stderr:
99
+ raise RuntimeError(f"Failed to delete secret: {result.stderr}")
100
+
101
+ def list(self) -> list[str]:
102
+ """List all secrets for this account in macOS Keychain."""
103
+ result = subprocess.run(
104
+ ["security", "dump-keychain"],
105
+ capture_output=True,
106
+ text=True,
107
+ )
108
+ if result.returncode != 0:
109
+ return []
110
+
111
+ secrets = []
112
+ current_account = None
113
+ current_service = None
114
+
115
+ for line in result.stdout.splitlines():
116
+ line = line.strip()
117
+ if '"acct"' in line and f'"{self.account}"' in line:
118
+ current_account = self.account
119
+ elif '"svce"' in line and current_account:
120
+ # Extract service name
121
+ # Format: "svce"<blob>="service_name"
122
+ if '="' in line:
123
+ current_service = line.split('="')[1].rstrip('"')
124
+ secrets.append(current_service)
125
+ current_account = None
126
+ current_service = None
127
+
128
+ return list(set(secrets)) # Deduplicate
129
+
130
+
131
+ class LinuxKeyring(SecretBackend):
132
+ """Linux keyring backend using the keyring library."""
133
+
134
+ def __init__(self, account: str):
135
+ super().__init__(account)
136
+ try:
137
+ import keyring
138
+ self.keyring = keyring
139
+ except ImportError:
140
+ raise RuntimeError(
141
+ "keyring library required on Linux. "
142
+ "Install with: pip install secretctl[linux]"
143
+ )
144
+
145
+ def get(self, name: str) -> str | None:
146
+ """Get a secret from Linux keyring."""
147
+ return self.keyring.get_password(self.account, name)
148
+
149
+ def set(self, name: str, value: str) -> None:
150
+ """Set a secret in Linux keyring."""
151
+ self.keyring.set_password(self.account, name, value)
152
+
153
+ def delete(self, name: str) -> None:
154
+ """Delete a secret from Linux keyring."""
155
+ try:
156
+ self.keyring.delete_password(self.account, name)
157
+ except self.keyring.errors.PasswordDeleteError:
158
+ pass # Already deleted or doesn't exist
159
+
160
+ def list(self) -> list[str]:
161
+ """List all secrets (not easily supported on Linux keyring)."""
162
+ # Linux keyring doesn't have a native list function
163
+ # This is a limitation - users need to track their own keys
164
+ return []
165
+
166
+
167
+ def get_backend(account: str) -> SecretBackend:
168
+ """Get the appropriate backend for the current platform."""
169
+ system = platform.system()
170
+
171
+ if system == "Darwin":
172
+ return MacOSKeychain(account)
173
+ elif system == "Linux":
174
+ return LinuxKeyring(account)
175
+ else:
176
+ raise RuntimeError(f"Unsupported platform: {system}")
secretctl/cli.py ADDED
@@ -0,0 +1,230 @@
1
+ """Main CLI entry point for secretctl."""
2
+
3
+ import json
4
+ import sys
5
+ import click
6
+
7
+ from . import __version__
8
+ from .backend import get_backend
9
+
10
+
11
+ class Context:
12
+ """Shared context for all commands."""
13
+
14
+ def __init__(self, json_output: bool = False, account: str = "secretctl"):
15
+ self.json_output = json_output
16
+ self.account = account
17
+ self.backend = get_backend(account)
18
+
19
+ def output(self, data: dict | list | str, success: bool = True):
20
+ """Output data in the appropriate format."""
21
+ if self.json_output:
22
+ if isinstance(data, str):
23
+ data = {"message": data, "success": success}
24
+ elif isinstance(data, dict) and "success" not in data:
25
+ data = {**data, "success": success}
26
+ click.echo(json.dumps(data, indent=2))
27
+ else:
28
+ if isinstance(data, dict):
29
+ for key, value in data.items():
30
+ if key != "success":
31
+ click.echo(f"{key}: {value}")
32
+ elif isinstance(data, list):
33
+ for item in data:
34
+ click.echo(item)
35
+ else:
36
+ click.echo(data)
37
+
38
+ def error(self, message: str, exit_code: int = 1):
39
+ """Output an error message and exit."""
40
+ if self.json_output:
41
+ click.echo(json.dumps({"error": message, "success": False}))
42
+ else:
43
+ click.echo(f"Error: {message}", err=True)
44
+ sys.exit(exit_code)
45
+
46
+
47
+ pass_context = click.make_pass_decorator(Context, ensure=True)
48
+
49
+
50
+ @click.group()
51
+ @click.option("--json", "json_output", is_flag=True, help="Output in JSON format")
52
+ @click.option("--account", "-a", default="secretctl", help="Account/namespace for secrets")
53
+ @click.version_option(version=__version__, prog_name="secretctl")
54
+ @click.pass_context
55
+ def main(ctx, json_output: bool, account: str):
56
+ """Cross-platform secret storage CLI for AI agents.
57
+
58
+ Store and retrieve secrets from the OS keychain without cloud sync
59
+ or biometrics. Designed for automation and AI agent workflows.
60
+
61
+ Examples:
62
+ secretctl set API_KEY sk-xxx
63
+ secretctl get API_KEY
64
+ secretctl list
65
+ """
66
+ ctx.obj = Context(json_output=json_output, account=account)
67
+
68
+
69
+ @main.command()
70
+ @click.argument("name")
71
+ @click.argument("value")
72
+ @pass_context
73
+ def set(ctx, name: str, value: str):
74
+ """Store a secret.
75
+
76
+ Example:
77
+ secretctl set API_KEY sk-xxx123
78
+ secretctl set DATABASE_URL "postgres://user:pass@host/db"
79
+ """
80
+ try:
81
+ ctx.backend.set(name, value)
82
+ ctx.output({"name": name, "stored": True, "account": ctx.account})
83
+ except Exception as e:
84
+ ctx.error(f"Failed to store secret: {e}")
85
+
86
+
87
+ @main.command()
88
+ @click.argument("name")
89
+ @pass_context
90
+ def get(ctx, name: str):
91
+ """Retrieve a secret.
92
+
93
+ Example:
94
+ secretctl get API_KEY
95
+ """
96
+ try:
97
+ value = ctx.backend.get(name)
98
+ if value is None:
99
+ ctx.error(f"Secret not found: {name}")
100
+ ctx.output({"name": name, "value": value})
101
+ except Exception as e:
102
+ ctx.error(f"Failed to retrieve secret: {e}")
103
+
104
+
105
+ @main.command()
106
+ @click.argument("name")
107
+ @pass_context
108
+ def delete(ctx, name: str):
109
+ """Delete a secret.
110
+
111
+ Example:
112
+ secretctl delete API_KEY
113
+ """
114
+ try:
115
+ ctx.backend.delete(name)
116
+ ctx.output({"name": name, "deleted": True})
117
+ except Exception as e:
118
+ ctx.error(f"Failed to delete secret: {e}")
119
+
120
+
121
+ @main.command("list")
122
+ @pass_context
123
+ def list_secrets(ctx):
124
+ """List all secret names (values hidden).
125
+
126
+ Example:
127
+ secretctl list
128
+ """
129
+ try:
130
+ secrets = ctx.backend.list()
131
+ ctx.output({"secrets": secrets, "count": len(secrets), "account": ctx.account})
132
+ except Exception as e:
133
+ ctx.error(f"Failed to list secrets: {e}")
134
+
135
+
136
+ @main.command()
137
+ @click.argument("name")
138
+ @pass_context
139
+ def exists(ctx, name: str):
140
+ """Check if a secret exists.
141
+
142
+ Example:
143
+ secretctl exists API_KEY
144
+ """
145
+ try:
146
+ value = ctx.backend.get(name)
147
+ exists = value is not None
148
+ ctx.output({"name": name, "exists": exists})
149
+ except Exception as e:
150
+ ctx.error(f"Failed to check secret: {e}")
151
+
152
+
153
+ @main.command()
154
+ @click.option("--file", "-f", "output_file", help="Output file (default: stdout)")
155
+ @click.option("--format", "fmt", type=click.Choice(["env", "json"]), default="env", help="Export format")
156
+ @pass_context
157
+ def export(ctx, output_file: str | None, fmt: str):
158
+ """Export all secrets.
159
+
160
+ Example:
161
+ secretctl export > secrets.env
162
+ secretctl export --format json > secrets.json
163
+ """
164
+ try:
165
+ secrets = ctx.backend.list()
166
+ data = {}
167
+ for name in secrets:
168
+ value = ctx.backend.get(name)
169
+ if value:
170
+ data[name] = value
171
+
172
+ if fmt == "json":
173
+ output = json.dumps(data, indent=2)
174
+ else: # env format
175
+ output = "\n".join(f'{k}="{v}"' for k, v in data.items())
176
+
177
+ if output_file:
178
+ with open(output_file, "w") as f:
179
+ f.write(output)
180
+ ctx.output({"exported": len(data), "file": output_file, "format": fmt})
181
+ else:
182
+ click.echo(output)
183
+ except Exception as e:
184
+ ctx.error(f"Failed to export secrets: {e}")
185
+
186
+
187
+ @main.command("import")
188
+ @click.argument("input_file", type=click.Path(exists=True))
189
+ @click.option("--format", "fmt", type=click.Choice(["env", "json"]), default="env", help="Import format")
190
+ @click.option("--overwrite", is_flag=True, help="Overwrite existing secrets")
191
+ @pass_context
192
+ def import_secrets(ctx, input_file: str, fmt: str, overwrite: bool):
193
+ """Import secrets from file.
194
+
195
+ Example:
196
+ secretctl import secrets.env
197
+ secretctl import secrets.json --format json --overwrite
198
+ """
199
+ try:
200
+ with open(input_file, "r") as f:
201
+ content = f.read()
202
+
203
+ if fmt == "json":
204
+ data = json.loads(content)
205
+ else: # env format
206
+ data = {}
207
+ for line in content.splitlines():
208
+ line = line.strip()
209
+ if line and not line.startswith("#") and "=" in line:
210
+ key, value = line.split("=", 1)
211
+ # Strip quotes if present
212
+ value = value.strip().strip('"').strip("'")
213
+ data[key.strip()] = value
214
+
215
+ imported = 0
216
+ skipped = 0
217
+ for name, value in data.items():
218
+ if not overwrite and ctx.backend.get(name) is not None:
219
+ skipped += 1
220
+ continue
221
+ ctx.backend.set(name, value)
222
+ imported += 1
223
+
224
+ ctx.output({"imported": imported, "skipped": skipped, "file": input_file})
225
+ except Exception as e:
226
+ ctx.error(f"Failed to import secrets: {e}")
227
+
228
+
229
+ if __name__ == "__main__":
230
+ main()
@@ -0,0 +1,130 @@
1
+ Metadata-Version: 2.4
2
+ Name: secretctl-cli
3
+ Version: 0.1.0
4
+ Summary: Cross-platform secret storage CLI for AI agents
5
+ Project-URL: Homepage, https://github.com/marcusbuildsthings-droid/secretctl
6
+ Project-URL: Repository, https://github.com/marcusbuildsthings-droid/secretctl
7
+ Author-email: Marcus <marcus.builds.things@gmail.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: ai-agent,cli,credentials,keychain,secrets
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: MacOS
16
+ Classifier: Operating System :: POSIX :: Linux
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Requires-Python: >=3.9
22
+ Requires-Dist: click>=8.0
23
+ Requires-Dist: rich>=13.0
24
+ Provides-Extra: linux
25
+ Requires-Dist: keyring>=24.0; extra == 'linux'
26
+ Description-Content-Type: text/markdown
27
+
28
+ # secretctl
29
+
30
+ Cross-platform secret storage CLI for AI agents and developers. Store secrets in the OS keychain without cloud sync or biometrics.
31
+
32
+ ## Installation
33
+
34
+ ```bash
35
+ pip install secretctl
36
+ ```
37
+
38
+ On Linux, install with keyring support:
39
+ ```bash
40
+ pip install secretctl[linux]
41
+ ```
42
+
43
+ ## Quick Start
44
+
45
+ ```bash
46
+ # Store a secret
47
+ secretctl set API_KEY sk-xxx123
48
+
49
+ # Retrieve a secret
50
+ secretctl get API_KEY
51
+
52
+ # List all secrets
53
+ secretctl list
54
+
55
+ # Delete a secret
56
+ secretctl delete API_KEY
57
+ ```
58
+
59
+ ## Commands
60
+
61
+ ### Basic Operations
62
+
63
+ ```bash
64
+ secretctl set <name> <value> # Store a secret
65
+ secretctl get <name> # Retrieve a secret
66
+ secretctl delete <name> # Delete a secret
67
+ secretctl list # List all secret names
68
+ secretctl exists <name> # Check if secret exists
69
+ ```
70
+
71
+ ### Import/Export
72
+
73
+ ```bash
74
+ # Export to env file
75
+ secretctl export > secrets.env
76
+ secretctl export --format json > secrets.json
77
+
78
+ # Import from file
79
+ secretctl import secrets.env
80
+ secretctl import secrets.json --format json
81
+ secretctl import secrets.env --overwrite # Replace existing
82
+ ```
83
+
84
+ ### Namespaces
85
+
86
+ Use different accounts/namespaces to organize secrets:
87
+
88
+ ```bash
89
+ secretctl --account myapp set DB_URL "postgres://..."
90
+ secretctl --account myapp list
91
+ ```
92
+
93
+ ## JSON Output
94
+
95
+ All commands support `--json` for machine-readable output:
96
+
97
+ ```bash
98
+ secretctl --json get API_KEY
99
+ ```
100
+
101
+ ```json
102
+ {
103
+ "name": "API_KEY",
104
+ "value": "sk-xxx123",
105
+ "success": true
106
+ }
107
+ ```
108
+
109
+ ## Platform Support
110
+
111
+ | Platform | Backend | Notes |
112
+ |----------|---------|-------|
113
+ | macOS | Keychain | Uses `security` command |
114
+ | Linux | keyring | Requires `secretctl[linux]` |
115
+
116
+ ## For AI Agents
117
+
118
+ See [SKILL.md](SKILL.md) for agent-optimized documentation.
119
+
120
+ ## Why secretctl?
121
+
122
+ - **No cloud sync** - Secrets stay local
123
+ - **No biometrics** - Works in automation/CI
124
+ - **Simple CLI** - Easy for agents to use
125
+ - **JSON output** - Machine-readable
126
+ - **Namespaced** - Multiple accounts/apps
127
+
128
+ ## License
129
+
130
+ MIT
@@ -0,0 +1,8 @@
1
+ secretctl/__init__.py,sha256=tk1IHS0BHdVrKCQFraLrU7t8Av-rGOfG3JMneM1KAp4,90
2
+ secretctl/backend.py,sha256=Sz4O_wrK-GrEgoIpH5cz9LaWKlbJf9wSk-bYmCyKdLs,5409
3
+ secretctl/cli.py,sha256=N5Rc8HYnfnyV-TmnJGlezWitFJtTlMvdJvcbZ8mWe4A,6833
4
+ secretctl_cli-0.1.0.dist-info/METADATA,sha256=ysNLdte-0x5NUrbNbK3Pk21YOXCt75GLIn7DfzjLCKg,3019
5
+ secretctl_cli-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
6
+ secretctl_cli-0.1.0.dist-info/entry_points.txt,sha256=piuuz-chGYfMG9c1FhvDPP4dVZ_d2doAPcq_e7O6i5U,49
7
+ secretctl_cli-0.1.0.dist-info/licenses/LICENSE,sha256=9tNBpWq8KGbuJqmeComp40OiNnbvpvsKn1YP26PUtck,1063
8
+ secretctl_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ secretctl = secretctl.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Marcus
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.