kc 0.1.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: acd1a1a4d4b98a8f4972a7be12a56485af6364a446ad9cc0981b92c0bae38e3b
4
+ data.tar.gz: 510e50ff16ddf681620775f0a1488615b5c68bafb8345d10e1447571762d68c9
5
+ SHA512:
6
+ metadata.gz: 70d120e84e72ef0c79c63c72eb69c21f1a14950c52a8cbc9d4765a6c555a4c163bf441668892a2f642c4dc3136c6466c7d577416352630ba7f912feed258f897
7
+ data.tar.gz: 5d05fae5c846ee424b5627b04770b4a3b3c4af5b1614985f48d18226ffab562a92366511ef61f4bea1f289993016f1920703978bf9e4e983ef7875385c31ac5a
data/.gitignore ADDED
@@ -0,0 +1,17 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /_yardoc/
4
+ /coverage/
5
+ /doc/
6
+ /pkg/
7
+ /spec/reports/
8
+ /tmp/
9
+ /vendor/bundle/
10
+
11
+ # rspec failure tracking
12
+ .rspec_status
13
+
14
+ # Test files
15
+ .env
16
+ *.gem
17
+ Gemfile.lock
data/.mise.toml ADDED
@@ -0,0 +1,2 @@
1
+ [tools]
2
+ ruby = "3.4.8"
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/.travis.yml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ sudo: false
3
+ language: ruby
4
+ cache: bundler
5
+ rvm:
6
+ - 2.6.10
7
+ before_install: gem install bundler -v 1.17.2
@@ -0,0 +1,74 @@
1
+ # Contributor Covenant Code of Conduct
2
+
3
+ ## Our Pledge
4
+
5
+ In the interest of fostering an open and welcoming environment, we as
6
+ contributors and maintainers pledge to making participation in our project and
7
+ our community a harassment-free experience for everyone, regardless of age, body
8
+ size, disability, ethnicity, gender identity and expression, level of experience,
9
+ nationality, personal appearance, race, religion, or sexual identity and
10
+ orientation.
11
+
12
+ ## Our Standards
13
+
14
+ Examples of behavior that contributes to creating a positive environment
15
+ include:
16
+
17
+ * Using welcoming and inclusive language
18
+ * Being respectful of differing viewpoints and experiences
19
+ * Gracefully accepting constructive criticism
20
+ * Focusing on what is best for the community
21
+ * Showing empathy towards other community members
22
+
23
+ Examples of unacceptable behavior by participants include:
24
+
25
+ * The use of sexualized language or imagery and unwelcome sexual attention or
26
+ advances
27
+ * Trolling, insulting/derogatory comments, and personal or political attacks
28
+ * Public or private harassment
29
+ * Publishing others' private information, such as a physical or electronic
30
+ address, without explicit permission
31
+ * Other conduct which could reasonably be considered inappropriate in a
32
+ professional setting
33
+
34
+ ## Our Responsibilities
35
+
36
+ Project maintainers are responsible for clarifying the standards of acceptable
37
+ behavior and are expected to take appropriate and fair corrective action in
38
+ response to any instances of unacceptable behavior.
39
+
40
+ Project maintainers have the right and responsibility to remove, edit, or
41
+ reject comments, commits, code, wiki edits, issues, and other contributions
42
+ that are not aligned to this Code of Conduct, or to ban temporarily or
43
+ permanently any contributor for other behaviors that they deem inappropriate,
44
+ threatening, offensive, or harmful.
45
+
46
+ ## Scope
47
+
48
+ This Code of Conduct applies both within project spaces and in public spaces
49
+ when an individual is representing the project or its community. Examples of
50
+ representing a project or community include using an official project e-mail
51
+ address, posting via an official social media account, or acting as an appointed
52
+ representative at an online or offline event. Representation of a project may be
53
+ further defined and clarified by project maintainers.
54
+
55
+ ## Enforcement
56
+
57
+ Instances of abusive, harassing, or otherwise unacceptable behavior may be
58
+ reported by contacting the project team at masa@aileron.cc. All
59
+ complaints will be reviewed and investigated and will result in a response that
60
+ is deemed necessary and appropriate to the circumstances. The project team is
61
+ obligated to maintain confidentiality with regard to the reporter of an incident.
62
+ Further details of specific enforcement policies may be posted separately.
63
+
64
+ Project maintainers who do not follow or enforce the Code of Conduct in good
65
+ faith may face temporary or permanent repercussions as determined by other
66
+ members of the project's leadership.
67
+
68
+ ## Attribution
69
+
70
+ This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
71
+ available at [http://contributor-covenant.org/version/1/4][version]
72
+
73
+ [homepage]: http://contributor-covenant.org
74
+ [version]: http://contributor-covenant.org/version/1/4/
data/Gemfile ADDED
@@ -0,0 +1,6 @@
1
+ source "https://rubygems.org"
2
+
3
+ git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }
4
+
5
+ # Specify your gem's dependencies in kc.gemspec
6
+ gemspec
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 AILERON
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
13
+ all 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
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,197 @@
1
+ # kc - Keychain Manager for direnv
2
+
3
+ A CLI tool to securely store and retrieve secrets from macOS Keychain with namespace support, designed to work seamlessly with direnv.
4
+
5
+ ## Features
6
+
7
+ - 🔐 Securely store any secrets in macOS Keychain
8
+ - 🏷️ **Namespace support** - organize secrets by type (env, ssh, token, etc.)
9
+ - 🚀 Native implementation using FFI (no shell command overhead)
10
+ - 🎯 Designed for direnv integration
11
+ - 📦 Simple CLI interface
12
+ - 📋 List and filter secrets by namespace
13
+
14
+ ## Installation
15
+
16
+ ```bash
17
+ gem install kc
18
+ ```
19
+
20
+ Or add to your Gemfile:
21
+
22
+ ```ruby
23
+ gem 'kc'
24
+ ```
25
+
26
+ ## Usage
27
+
28
+ All commands require a **namespace** in the format `<namespace>:<name>`. Namespaces help organize different types of secrets.
29
+
30
+ ### Save to Keychain
31
+
32
+ Read from stdin and save to keychain with namespace:
33
+
34
+ ```bash
35
+ # Environment variables
36
+ kc save env:myproject < .env
37
+ cat .env | kc save env:production
38
+
39
+ # SSH keys
40
+ kc save ssh:id_rsa < ~/.ssh/id_rsa
41
+ kc save ssh:deploy-key < deploy_key
42
+
43
+ # API tokens
44
+ echo "ghp_xxxxxxxxxxxx" | kc save token:github
45
+ kc save token:openai < api_token.txt
46
+
47
+ # Certificates
48
+ kc save cert:ssl-cert < certificate.pem
49
+
50
+ # Custom namespaces
51
+ kc save my-app:config < config.json
52
+ ```
53
+
54
+ ### Load from Keychain
55
+
56
+ Output to stdout or redirect to file:
57
+
58
+ ```bash
59
+ kc load env:myproject
60
+ kc load env:myproject > .env
61
+ kc load ssh:id_rsa > ~/.ssh/id_rsa
62
+ ```
63
+
64
+ ### List Entries
65
+
66
+ ```bash
67
+ # List all entries
68
+ kc list
69
+
70
+ # List entries in specific namespace
71
+ kc list env:
72
+ kc list ssh:
73
+ kc list token:
74
+ ```
75
+
76
+ ### Delete from Keychain
77
+
78
+ ```bash
79
+ kc delete env:myproject
80
+ kc delete ssh:id_rsa
81
+ kc delete token:github
82
+ ```
83
+
84
+ ### Use with direnv
85
+
86
+ In your `.envrc`:
87
+
88
+ ```bash
89
+ # Load from keychain and export all variables
90
+ eval "$(kc load env:myproject | sed 's/^/export /')"
91
+
92
+ # Or restore .env file
93
+ kc load env:myproject > .env
94
+ source_env .env
95
+ ```
96
+
97
+ ## Commands
98
+
99
+ - `kc save <namespace>:<name>` - Read from stdin and save to keychain
100
+ - `kc load <namespace>:<name>` - Load from keychain and output to stdout
101
+ - `kc delete <namespace>:<name>` - Delete entry from keychain
102
+ - `kc list [prefix]` - List all entries (optionally filter by prefix)
103
+
104
+ ## Namespaces
105
+
106
+ Namespaces must contain only lowercase letters, numbers, and hyphens.
107
+
108
+ **Common namespaces:**
109
+ - `env:` - Environment variable files
110
+ - `ssh:` - SSH keys
111
+ - `token:` - API tokens
112
+ - `cert:` - Certificates
113
+ - `key:` - Encryption keys
114
+ - `secret:` - General secrets
115
+
116
+ You can create custom namespaces as needed.
117
+
118
+ ## How it works
119
+
120
+ `kc` uses macOS Security framework via FFI to directly interact with the Keychain, avoiding shell command overhead. All entries are stored under:
121
+
122
+ - Service name: `kc`
123
+ - Account name: `<namespace>:<name>` (e.g., `env:myproject`)
124
+
125
+ ## Full Workflow Example
126
+
127
+ ```bash
128
+ # Save environment variables for different environments
129
+ kc save env:development < .env.development
130
+ kc save env:staging < .env.staging
131
+ kc save env:production < .env.production
132
+
133
+ # Save SSH keys
134
+ kc save ssh:personal < ~/.ssh/id_rsa
135
+ kc save ssh:work < ~/.ssh/id_rsa_work
136
+
137
+ # Save API tokens
138
+ echo "ghp_xxxxxxxxxxxx" | kc save token:github
139
+ echo "sk-xxxxxxxxxxxxxx" | kc save token:openai
140
+
141
+ # List all secrets
142
+ kc list
143
+ # => env:development
144
+ # => env:production
145
+ # => env:staging
146
+ # => ssh:personal
147
+ # => ssh:work
148
+ # => token:github
149
+ # => token:openai
150
+
151
+ # List only environment files
152
+ kc list env:
153
+ # => env:development
154
+ # => env:production
155
+ # => env:staging
156
+
157
+ # Load and use in direnv
158
+ # .envrc file:
159
+ eval "$(kc load env:development | sed 's/^/export /')"
160
+
161
+ # Or check if exists before loading
162
+ if kc list env:production > /dev/null 2>&1; then
163
+ kc load env:production > .env
164
+ else
165
+ echo "No production env found"
166
+ fi
167
+
168
+ # Clean up when done
169
+ kc delete env:development
170
+ kc delete ssh:personal
171
+ ```
172
+
173
+ ## Development
174
+
175
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests.
176
+
177
+ ```bash
178
+ bundle install
179
+ bundle exec rspec
180
+ ```
181
+
182
+ ## Requirements
183
+
184
+ - macOS (uses macOS Keychain)
185
+ - Ruby 2.5 or later
186
+
187
+ ## Contributing
188
+
189
+ Bug reports and pull requests are welcome on GitHub at https://github.com/aileron-inc/kc.
190
+
191
+ ## License
192
+
193
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
194
+
195
+ ## Code of Conduct
196
+
197
+ Everyone interacting in the Kc project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/aileron-inc/kc/blob/master/CODE_OF_CONDUCT.md).
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task :default => :spec
data/bin/console ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "kc"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "irb"
14
+ IRB.start(__FILE__)
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
data/exe/kc ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "kc"
4
+
5
+ Kc::CLI.start(ARGV)
data/kc.gemspec ADDED
@@ -0,0 +1,41 @@
1
+
2
+ lib = File.expand_path("../lib", __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require "kc/version"
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "kc"
8
+ spec.version = Kc::VERSION
9
+ spec.authors = ["AILERON"]
10
+ spec.email = ["masa@aileron.cc"]
11
+
12
+ spec.summary = %q{Manage .env files in Mac Keychain with direnv}
13
+ spec.description = %q{A CLI tool to save and load .env files from Mac Keychain, designed to work seamlessly with direnv}
14
+ spec.homepage = "https://github.com/aileron/kc"
15
+ spec.license = "MIT"
16
+
17
+ # Prevent pushing this gem to RubyGems.org. To allow pushes either set the 'allowed_push_host'
18
+ # to allow pushing to a single host or delete this section to allow pushing to any host.
19
+ if spec.respond_to?(:metadata)
20
+ spec.metadata["homepage_uri"] = spec.homepage
21
+ spec.metadata["source_code_uri"] = "https://github.com/aileron/kc"
22
+ else
23
+ raise "RubyGems 2.0 or newer is required to protect against " \
24
+ "public gem pushes."
25
+ end
26
+
27
+ # Specify which files should be added to the gem when it is released.
28
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
29
+ spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do
30
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
31
+ end
32
+ spec.bindir = "exe"
33
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
34
+ spec.require_paths = ["lib"]
35
+
36
+ spec.add_dependency "ffi", "~> 1.15"
37
+
38
+ spec.add_development_dependency "bundler"
39
+ spec.add_development_dependency "rake"
40
+ spec.add_development_dependency "rspec", "~> 3.0"
41
+ end
data/lib/kc/cli.rb ADDED
@@ -0,0 +1,148 @@
1
+ module Kc
2
+ class CLI
3
+ def self.start(argv)
4
+ new(argv).run
5
+ end
6
+
7
+ def initialize(argv)
8
+ @argv = argv
9
+ end
10
+
11
+ def run
12
+ command = @argv[0]
13
+ name = @argv[1]
14
+
15
+ case command
16
+ when "save"
17
+ handle_save(name)
18
+ when "load"
19
+ handle_load(name)
20
+ when "delete"
21
+ handle_delete(name)
22
+ when "list"
23
+ handle_list(name)
24
+ else
25
+ show_usage
26
+ exit 1
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def validate_namespace(name)
33
+ unless name&.include?(":")
34
+ puts "Error: Namespace required. Format: <namespace>:<name>"
35
+ puts "Examples: env:myproject, ssh:id_rsa, token:github"
36
+ exit 1
37
+ end
38
+
39
+ namespace, key = name.split(":", 2)
40
+
41
+ if namespace.empty? || key.empty?
42
+ puts "Error: Invalid format. Use <namespace>:<name>"
43
+ exit 1
44
+ end
45
+
46
+ # Validate namespace format (alphanumeric and hyphen only)
47
+ unless namespace.match?(/^[a-z0-9-]+$/)
48
+ puts "Error: Namespace must contain only lowercase letters, numbers, and hyphens"
49
+ exit 1
50
+ end
51
+ end
52
+
53
+ def handle_save(name)
54
+ unless name
55
+ puts "Error: name is required"
56
+ show_usage
57
+ exit 1
58
+ end
59
+
60
+ validate_namespace(name)
61
+
62
+ # Read from stdin
63
+ if STDIN.tty?
64
+ puts "Error: No input provided. Use: cat file | kc save <namespace>:<name>"
65
+ exit 1
66
+ end
67
+
68
+ content = STDIN.read
69
+ if content.empty?
70
+ puts "Error: Input is empty"
71
+ exit 1
72
+ end
73
+
74
+ Keychain.save(name, content)
75
+ puts "Successfully saved to keychain as '#{name}'"
76
+ rescue => e
77
+ puts "Error: #{e.message}"
78
+ exit 1
79
+ end
80
+
81
+ def handle_load(name)
82
+ unless name
83
+ puts "Error: name is required"
84
+ show_usage
85
+ exit 1
86
+ end
87
+
88
+ validate_namespace(name)
89
+
90
+ content = Keychain.load(name)
91
+ puts content
92
+ rescue => e
93
+ puts "Error: #{e.message}"
94
+ exit 1
95
+ end
96
+
97
+ def handle_delete(name)
98
+ unless name
99
+ puts "Error: name is required"
100
+ show_usage
101
+ exit 1
102
+ end
103
+
104
+ validate_namespace(name)
105
+
106
+ Keychain.delete(name)
107
+ puts "Successfully deleted '#{name}' from keychain"
108
+ rescue => e
109
+ puts "Error: #{e.message}"
110
+ exit 1
111
+ end
112
+
113
+ def handle_list(prefix)
114
+ items = Keychain.list(prefix)
115
+
116
+ if items.empty?
117
+ if prefix
118
+ puts "No items found with prefix '#{prefix}'"
119
+ else
120
+ puts "No items found in keychain"
121
+ end
122
+ else
123
+ items.each { |item| puts item }
124
+ end
125
+ rescue => e
126
+ puts "Error: #{e.message}"
127
+ exit 1
128
+ end
129
+
130
+ def show_usage
131
+ puts <<~USAGE
132
+ Usage:
133
+ kc save <namespace>:<name> Save from stdin to keychain
134
+ kc load <namespace>:<name> Load from keychain to stdout
135
+ kc delete <namespace>:<name> Delete from keychain
136
+ kc list [prefix] List all items (optionally filter by prefix)
137
+
138
+ Examples:
139
+ cat .env | kc save env:myproject
140
+ kc load env:myproject > .env
141
+ kc save ssh:id_rsa < ~/.ssh/id_rsa
142
+ kc list # List all
143
+ kc list env: # List all env: items
144
+ kc delete env:myproject
145
+ USAGE
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,228 @@
1
+ require "ffi"
2
+
3
+ module Kc
4
+ class Keychain
5
+ SERVICE_NAME = "kc"
6
+
7
+ module SecurityFramework
8
+ extend FFI::Library
9
+ ffi_lib "/System/Library/Frameworks/Security.framework/Security"
10
+
11
+ # OSStatus SecKeychainAddGenericPassword(
12
+ # SecKeychainRef keychain,
13
+ # UInt32 serviceNameLength,
14
+ # const char *serviceName,
15
+ # UInt32 accountNameLength,
16
+ # const char *accountName,
17
+ # UInt32 passwordLength,
18
+ # const void *passwordData,
19
+ # SecKeychainItemRef *itemRef
20
+ # )
21
+ attach_function :SecKeychainAddGenericPassword, [
22
+ :pointer, # keychain (NULL for default)
23
+ :uint32, # serviceNameLength
24
+ :string, # serviceName
25
+ :uint32, # accountNameLength
26
+ :string, # accountName
27
+ :uint32, # passwordLength
28
+ :pointer, # passwordData
29
+ :pointer # itemRef (can be NULL)
30
+ ], :int
31
+
32
+ # OSStatus SecKeychainFindGenericPassword(
33
+ # CFTypeRef keychainOrArray,
34
+ # UInt32 serviceNameLength,
35
+ # const char *serviceName,
36
+ # UInt32 accountNameLength,
37
+ # const char *accountName,
38
+ # UInt32 *passwordLength,
39
+ # void **passwordData,
40
+ # SecKeychainItemRef *itemRef
41
+ # )
42
+ attach_function :SecKeychainFindGenericPassword, [
43
+ :pointer, # keychainOrArray (NULL for default)
44
+ :uint32, # serviceNameLength
45
+ :string, # serviceName
46
+ :uint32, # accountNameLength
47
+ :string, # accountName
48
+ :pointer, # passwordLength (output)
49
+ :pointer, # passwordData (output)
50
+ :pointer # itemRef (output, can be NULL if not needed)
51
+ ], :int
52
+
53
+ # OSStatus SecKeychainItemDelete(SecKeychainItemRef itemRef)
54
+ attach_function :SecKeychainItemDelete, [:pointer], :int
55
+
56
+ # void SecKeychainItemFreeContent(SecKeychainAttributeList *attrList, void *data)
57
+ attach_function :SecKeychainItemFreeContent, [:pointer, :pointer], :int
58
+
59
+ # Constants for attribute tags
60
+ KSecAccountItemAttr = 0x61636374 # 'acct' in FourCC
61
+
62
+ # Attribute structure
63
+ class SecKeychainAttribute < FFI::Struct
64
+ layout :tag, :uint32,
65
+ :length, :uint32,
66
+ :data, :pointer
67
+ end
68
+
69
+ class SecKeychainAttributeList < FFI::Struct
70
+ layout :count, :uint32,
71
+ :attr, :pointer # Array of SecKeychainAttribute
72
+ end
73
+
74
+ # OSStatus SecKeychainSearchCreateFromAttributes(
75
+ # CFTypeRef keychainOrArray,
76
+ # SecItemClass itemClass,
77
+ # const SecKeychainAttributeList *attrList,
78
+ # SecKeychainSearchRef *searchRef
79
+ # )
80
+ attach_function :SecKeychainSearchCreateFromAttributes, [
81
+ :pointer, # keychainOrArray
82
+ :uint32, # itemClass (kSecGenericPasswordItemClass = 'genp')
83
+ :pointer, # attrList
84
+ :pointer # searchRef (output)
85
+ ], :int
86
+
87
+ # OSStatus SecKeychainSearchCopyNext(
88
+ # SecKeychainSearchRef searchRef,
89
+ # SecKeychainItemRef *itemRef
90
+ # )
91
+ attach_function :SecKeychainSearchCopyNext, [
92
+ :pointer, # searchRef
93
+ :pointer # itemRef (output)
94
+ ], :int
95
+
96
+ # OSStatus SecKeychainItemCopyAttributesAndData(
97
+ # SecKeychainItemRef itemRef,
98
+ # SecKeychainAttributeInfo *info,
99
+ # SecItemClass *itemClass,
100
+ # SecKeychainAttributeList **attrList,
101
+ # UInt32 *length,
102
+ # void **outData
103
+ # )
104
+ attach_function :SecKeychainItemCopyAttributesAndData, [
105
+ :pointer, # itemRef
106
+ :pointer, # info (NULL for default keychain)
107
+ :pointer, # itemClass (can be NULL)
108
+ :pointer, # attrList (output)
109
+ :pointer, # length (can be NULL)
110
+ :pointer # outData (can be NULL)
111
+ ], :int
112
+
113
+ # OSStatus SecKeychainItemFreeAttributesAndData(
114
+ # SecKeychainAttributeList *attrList,
115
+ # void *data
116
+ # )
117
+ attach_function :SecKeychainItemFreeAttributesAndData, [:pointer, :pointer], :int
118
+
119
+ # void CFRelease(CFTypeRef cf)
120
+ attach_function :CFRelease, [:pointer], :void
121
+ end
122
+
123
+ class << self
124
+ def save(account_name, content)
125
+ # Try to delete existing entry first
126
+ delete(account_name) rescue nil
127
+
128
+ # Add new entry
129
+ status = SecurityFramework.SecKeychainAddGenericPassword(
130
+ nil, # default keychain
131
+ SERVICE_NAME.bytesize, # service name length
132
+ SERVICE_NAME, # service name
133
+ account_name.bytesize, # account name length
134
+ account_name, # account name
135
+ content.bytesize, # password length
136
+ FFI::MemoryPointer.from_string(content), # password data
137
+ nil # don't need item ref
138
+ )
139
+
140
+ unless status.zero?
141
+ raise Error, "Failed to save to keychain (status: #{status})"
142
+ end
143
+ end
144
+
145
+ def load(account_name)
146
+ password_length = FFI::MemoryPointer.new(:uint32)
147
+ password_data = FFI::MemoryPointer.new(:pointer)
148
+
149
+ status = SecurityFramework.SecKeychainFindGenericPassword(
150
+ nil, # default keychain
151
+ SERVICE_NAME.bytesize, # service name length
152
+ SERVICE_NAME, # service name
153
+ account_name.bytesize, # account name length
154
+ account_name, # account name
155
+ password_length, # password length (output)
156
+ password_data, # password data (output)
157
+ nil # don't need item ref
158
+ )
159
+
160
+ unless status.zero?
161
+ raise Error, "Failed to load from keychain (status: #{status})"
162
+ end
163
+
164
+ # Read the password data
165
+ length = password_length.read_uint32
166
+ data_ptr = password_data.read_pointer
167
+ password = data_ptr.read_string(length)
168
+
169
+ # Free the memory allocated by Security framework
170
+ SecurityFramework.SecKeychainItemFreeContent(nil, data_ptr)
171
+
172
+ password
173
+ end
174
+
175
+ def delete(account_name)
176
+ item_ref = FFI::MemoryPointer.new(:pointer)
177
+
178
+ status = SecurityFramework.SecKeychainFindGenericPassword(
179
+ nil,
180
+ SERVICE_NAME.bytesize,
181
+ SERVICE_NAME,
182
+ account_name.bytesize,
183
+ account_name,
184
+ nil,
185
+ nil,
186
+ item_ref
187
+ )
188
+
189
+ unless status.zero?
190
+ raise Error, "Entry '#{account_name}' not found in keychain"
191
+ end
192
+
193
+ delete_status = SecurityFramework.SecKeychainItemDelete(item_ref.read_pointer)
194
+
195
+ unless delete_status.zero?
196
+ raise Error, "Failed to delete from keychain (status: #{delete_status})"
197
+ end
198
+ end
199
+
200
+ def list(prefix = nil)
201
+ accounts = []
202
+
203
+ # Use security dump-keychain and parse output
204
+ # We need to look for entries with service="kc"
205
+ output = `security dump-keychain login.keychain-db 2>/dev/null`
206
+
207
+ # Split by keychain entries
208
+ entries = output.split(/^keychain:/).drop(1)
209
+
210
+ entries.each do |entry|
211
+ # Check if this entry has svce="kc"
212
+ if entry =~ /"svce"<blob>="#{SERVICE_NAME}"/
213
+ # Extract account name
214
+ if entry =~ /"acct"<blob>="([^"]+)"/
215
+ account_name = $1
216
+ # Filter by prefix if provided
217
+ if prefix.nil? || account_name.start_with?(prefix)
218
+ accounts << account_name
219
+ end
220
+ end
221
+ end
222
+ end
223
+
224
+ accounts.sort.uniq
225
+ end
226
+ end
227
+ end
228
+ end
data/lib/kc/version.rb ADDED
@@ -0,0 +1,3 @@
1
+ module Kc
2
+ VERSION = "0.1.0"
3
+ end
data/lib/kc.rb ADDED
@@ -0,0 +1,7 @@
1
+ require "kc/version"
2
+ require "kc/cli"
3
+ require "kc/keychain"
4
+
5
+ module Kc
6
+ class Error < StandardError; end
7
+ end
metadata ADDED
@@ -0,0 +1,117 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: kc
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - AILERON
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: ffi
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.15'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.15'
26
+ - !ruby/object:Gem::Dependency
27
+ name: bundler
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: rake
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: rspec
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '3.0'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '3.0'
68
+ description: A CLI tool to save and load .env files from Mac Keychain, designed to
69
+ work seamlessly with direnv
70
+ email:
71
+ - masa@aileron.cc
72
+ executables:
73
+ - kc
74
+ extensions: []
75
+ extra_rdoc_files: []
76
+ files:
77
+ - ".gitignore"
78
+ - ".mise.toml"
79
+ - ".rspec"
80
+ - ".travis.yml"
81
+ - CODE_OF_CONDUCT.md
82
+ - Gemfile
83
+ - LICENSE.txt
84
+ - README.md
85
+ - Rakefile
86
+ - bin/console
87
+ - bin/setup
88
+ - exe/kc
89
+ - kc.gemspec
90
+ - lib/kc.rb
91
+ - lib/kc/cli.rb
92
+ - lib/kc/keychain.rb
93
+ - lib/kc/version.rb
94
+ homepage: https://github.com/aileron/kc
95
+ licenses:
96
+ - MIT
97
+ metadata:
98
+ homepage_uri: https://github.com/aileron/kc
99
+ source_code_uri: https://github.com/aileron/kc
100
+ rdoc_options: []
101
+ require_paths:
102
+ - lib
103
+ required_ruby_version: !ruby/object:Gem::Requirement
104
+ requirements:
105
+ - - ">="
106
+ - !ruby/object:Gem::Version
107
+ version: '0'
108
+ required_rubygems_version: !ruby/object:Gem::Requirement
109
+ requirements:
110
+ - - ">="
111
+ - !ruby/object:Gem::Version
112
+ version: '0'
113
+ requirements: []
114
+ rubygems_version: 3.6.9
115
+ specification_version: 4
116
+ summary: Manage .env files in Mac Keychain with direnv
117
+ test_files: []