intent-record 1.0.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.
Files changed (64) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +18 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +137 -0
  5. data/db/migrate/20260918000001_create_initial_schema.rb +68 -0
  6. data/exe/intent-record +5 -0
  7. data/lib/intent_record/application_record.rb +8 -0
  8. data/lib/intent_record/asset_version_normalizer.rb +43 -0
  9. data/lib/intent_record/asset_version_resolver.rb +76 -0
  10. data/lib/intent_record/cli/argv_parser.rb +78 -0
  11. data/lib/intent_record/cli/dispatch.rb +118 -0
  12. data/lib/intent_record/cli/stdin_json.rb +28 -0
  13. data/lib/intent_record/cli/streams.rb +9 -0
  14. data/lib/intent_record/cli/usage.rb +29 -0
  15. data/lib/intent_record/cli.rb +69 -0
  16. data/lib/intent_record/commands/attach.rb +39 -0
  17. data/lib/intent_record/commands/by_source.rb +52 -0
  18. data/lib/intent_record/commands/lookup.rb +22 -0
  19. data/lib/intent_record/commands/recent.rb +21 -0
  20. data/lib/intent_record/commands/record.rb +52 -0
  21. data/lib/intent_record/commands/search.rb +50 -0
  22. data/lib/intent_record/commands/show.rb +19 -0
  23. data/lib/intent_record/commands/systems.rb +16 -0
  24. data/lib/intent_record/config.rb +51 -0
  25. data/lib/intent_record/database.rb +74 -0
  26. data/lib/intent_record/formatter.rb +59 -0
  27. data/lib/intent_record/global_id.rb +22 -0
  28. data/lib/intent_record/input_validator.rb +69 -0
  29. data/lib/intent_record/like_pattern.rb +34 -0
  30. data/lib/intent_record/linkers/asset_version_linker.rb +43 -0
  31. data/lib/intent_record/linkers/asset_version_specs.rb +30 -0
  32. data/lib/intent_record/linkers/intent_linker.rb +35 -0
  33. data/lib/intent_record/linkers/stakeholder_linker.rb +61 -0
  34. data/lib/intent_record/models/asset_version.rb +12 -0
  35. data/lib/intent_record/models/intent_record.rb +29 -0
  36. data/lib/intent_record/models/intent_record_asset_version.rb +11 -0
  37. data/lib/intent_record/models/intent_record_link.rb +22 -0
  38. data/lib/intent_record/models/stakeholder_reference.rb +11 -0
  39. data/lib/intent_record/models/stakeholder_source.rb +12 -0
  40. data/lib/intent_record/models/stakeholder_system.rb +10 -0
  41. data/lib/intent_record/models/vcs_system.rb +10 -0
  42. data/lib/intent_record/seeds.rb +38 -0
  43. data/lib/intent_record/sqlite_connection_setup.rb +22 -0
  44. data/lib/intent_record/stakeholder_normalizer.rb +33 -0
  45. data/lib/intent_record/version.rb +3 -0
  46. data/lib/intent_record/web/app.rb +54 -0
  47. data/lib/intent_record/web/boot.rb +51 -0
  48. data/lib/intent_record/web/public/styles.css +50 -0
  49. data/lib/intent_record/web/query_router.rb +25 -0
  50. data/lib/intent_record/web/routes/commits.rb +16 -0
  51. data/lib/intent_record/web/routes/home.rb +21 -0
  52. data/lib/intent_record/web/routes/intents.rb +15 -0
  53. data/lib/intent_record/web/routes/search.rb +17 -0
  54. data/lib/intent_record/web/routes/sources.rb +19 -0
  55. data/lib/intent_record/web/views/_intent_cards.erb +43 -0
  56. data/lib/intent_record/web/views/commit.erb +12 -0
  57. data/lib/intent_record/web/views/error.erb +7 -0
  58. data/lib/intent_record/web/views/home.erb +14 -0
  59. data/lib/intent_record/web/views/intent.erb +5 -0
  60. data/lib/intent_record/web/views/layout.erb +28 -0
  61. data/lib/intent_record/web/views/search.erb +5 -0
  62. data/lib/intent_record/web/views/sources.erb +16 -0
  63. data/lib/intent_record.rb +28 -0
  64. metadata +194 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 871b5d799ddc9505ac23dbbd3bf468d243ae3c48a4c04574e9fd0fc596a579ae
4
+ data.tar.gz: f6370c0f8003d35971d1b717c1ca7d24632b38b9568496c22e7605838397f9d6
5
+ SHA512:
6
+ metadata.gz: 7d0ef27f3db2d4bf6616ea46f225462e778dad93c6433738d90f03e7c68fac5c2ee196681f1445a15673915d5ac449f0737d12974d01af97cdb618a4530f2759
7
+ data.tar.gz: 5dc2d7077843e89318b6d8d5ef7238663eb531e8ab01633a63f7a40c96f025577751e2a757f27fa64e8e1c31a81eef4ca9c8829d818fdd0f4bffbb1faa61867a
data/CHANGELOG.md ADDED
@@ -0,0 +1,18 @@
1
+ # Changelog
2
+
3
+ Notable changes, one section per released version. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses
5
+ [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## Unreleased
8
+
9
+ ## 1.0.0 - 2026-09-18
10
+
11
+ First release.
12
+
13
+ - `record`, `attach`, `show`, `lookup`, `search`, `by-source`, `recent`, `systems` and `serve`. JSON in on stdin, JSON out on stdout, exit 1 with `{"error": ...}` on failure. Argv a command does not recognise is refused rather than ignored.
14
+ - SQLite store at `~/.intent-record/`, created and migrated on first use. `--config-dir` points one command at another store, `INTENT_RECORD_CONFIG_DIR` points a whole shell at one.
15
+ - An intent links to any number of commits, to stakeholder sources such as Jira, Confluence and Linear, and to earlier intents it builds on. Commits can be attached after the fact, which covers rebases and squashes.
16
+ - Prefix lookup from four characters for hash-based systems. An id recorded in two systems is reported as ambiguous rather than answered with one of them.
17
+ - Every list comes back in an order the query names, in both the CLI and the GUI.
18
+ - Local web GUI on 127.0.0.1 with commit, intent, search and stakeholder source pages.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Erik T. Madsen
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.
data/README.md ADDED
@@ -0,0 +1,137 @@
1
+ # intent-record
2
+
3
+ [![CI](https://github.com/beatmadsen/intent-record/actions/workflows/ci.yml/badge.svg)](https://github.com/beatmadsen/intent-record/actions/workflows/ci.yml)
4
+
5
+ Local records storage for the intent behind individual code changes.
6
+
7
+ A commit message says what changed. The intent record says why: what the agent (or person) was trying to achieve, which ticket or design page asked for it, and which earlier change it builds on. intent-record stores that in a local SQLite database, links it to the commit hash, and answers the questions people and agents ask later.
8
+
9
+ - "What was this commit for?" (`lookup <hash>`)
10
+ - "What did we build for this Jira ticket, and why that way?" (`by-source <url-or-key>`)
11
+ - "Have we touched retry logic before, and what were we thinking?" (`search retry`)
12
+
13
+ ## Installation
14
+
15
+ ```bash
16
+ gem install intent-record
17
+ ```
18
+
19
+ On first run a config directory is created at `~/.intent-record/` with the database at `~/.intent-record/intent-record.db`. Edit `~/.intent-record/config.yml` to move the database.
20
+
21
+ To use a different store for one command, pass `--config-dir <dir>` before or after the command name. `INTENT_RECORD_CONFIG_DIR` does the same for a whole shell, except that a misspelled variable name is ignored without a word while a misspelled flag is refused.
22
+
23
+ ```bash
24
+ intent-record --config-dir /tmp/scratch recent
25
+ ```
26
+
27
+ ## Quick start
28
+
29
+ ```bash
30
+ # After committing a change, record why it was made
31
+ echo '{
32
+ "summary": "Retry flaky fetches with backoff",
33
+ "body": "CI failed three times this week on transient DNS errors. Retrying 3x with exponential backoff instead of failing the build.",
34
+ "author": "claude",
35
+ "commits": ["8f3a1c2d9e4b5f60718293a4b5c6d7e8f9a0b1c2"],
36
+ "stakeholder_references": [
37
+ {"system": "jira", "uri": "https://acme.atlassian.net/browse/ACME-42", "title": "Flaky fetch in CI"}
38
+ ]
39
+ }' | intent-record record
40
+ # => {"intent_id":"EUt4WMY","summary":"Retry flaky fetches with backoff", ... full record ...}
41
+
42
+ # Later, someone stares at that commit in git blame
43
+ intent-record lookup 8f3a1c2
44
+
45
+ # Or comes at it from the ticket
46
+ intent-record by-source ACME-42 --contains
47
+
48
+ # Or wants to read it in a browser
49
+ intent-record serve
50
+ ```
51
+
52
+ ## Commands
53
+
54
+ JSON in on stdin where input is needed, JSON out on stdout, exit code 0 on success and 1 with `{"error": "..."}` otherwise.
55
+
56
+ | Command | Description |
57
+ |---|---|
58
+ | `record` | Record the intent behind a change (JSON via stdin) |
59
+ | `attach <intent_id>` | Link more commits, stakeholder references or related intents to an existing record (JSON via stdin) |
60
+ | `show <intent_id>` | Full intent record with commits, stakeholder links and related intents |
61
+ | `lookup <commit> [--vcs name]` | All intents recorded against a commit. Accepts a full hash or a unique prefix of at least 4 characters. Ids are unique per system, not across them, so an id recorded in two systems is reported as ambiguous; name one with `--vcs` |
62
+ | `search <terms...> [--match all]` | Case-insensitive substring search over summary, body, and linked stakeholder URIs and titles, so a ticket key finds its intents |
63
+ | `by-source <uri> [--contains]` | Intents linked to a stakeholder source, plus the distinct commits across them. `--contains` matches a substring such as a ticket key |
64
+ | `recent [--limit N]` | Newest intents first |
65
+ | `systems` | Known VCS and stakeholder system names |
66
+ | `serve [--port N]` | Start the web GUI on 127.0.0.1 (default port 4791) |
67
+
68
+ ### Input shape for `record` and `attach`
69
+
70
+ ```json
71
+ {
72
+ "summary": "one line, at most 350 characters (record only, required)",
73
+ "body": "the reasoning, as long as it needs to be (record only, required)",
74
+ "author": "optional: agent or person name",
75
+ "commits": ["git hashes; shorthand for asset_versions with vcs git"],
76
+ "asset_versions": [{"vcs": "perforce", "external_id": "12345"}],
77
+ "stakeholder_references": [{"system": "jira", "uri": "https://...", "title": "optional"}],
78
+ "related_intent_ids": ["intent ids this change builds on"]
79
+ }
80
+ ```
81
+
82
+ Options take either `--name value` or `--name=value`. Unknown options and stray arguments are rejected rather than ignored.
83
+
84
+ `lookup` without `--vcs` searches every system and prefers an exact id match over a prefix match. Git commits must be full SHA-1 or SHA-256 hashes; they are stored lowercase. Prefix lookup applies to hash-based systems only (git, mercurial, fossil, sapling, pijul, darcs), so a Perforce changelist `1234` never matches `12345`. URIs are normalised (lowercase scheme and host, no trailing slash) and system names are lowercased and hyphenated, so `Jira` and `jira`, or a ticket URL with and without a trailing slash, are one source. A later non-blank `title` for a source replaces the stored one.
85
+
86
+ `record` and `attach` both return the full intent record. A record's commits, stakeholder references and related intents come back in the order they were attached.
87
+
88
+ An intent can be recorded before the commit exists and linked with `attach` afterwards. This also covers rebases and squashes, where the same intent ends up on a new hash. One commit can carry several intents and one intent can span several commits.
89
+
90
+ ## Data model
91
+
92
+ Commit hashes are treated as globally unique, so the store does not track which repository a commit belongs to. VCS and stakeholder system names are lowercased on the way in, and the common ones are seeded on first connect; `intent-record systems` lists them. Unknown names are added on first use.
93
+
94
+ ```
95
+ vcs_systems (name)
96
+ asset_versions (vcs_system, external_id) a commit, changelist, revision
97
+ intent_records (global_id, summary, body, author)
98
+ intent_record_asset_versions intent <-> asset version, many to many
99
+ stakeholder_systems (name)
100
+ stakeholder_sources (stakeholder_system, uri, title) a ticket, page, thread
101
+ stakeholder_references intent <-> source, many to many
102
+ intent_record_links (source, target) intent builds on intent
103
+ ```
104
+
105
+ Intent ids are random 7-character base58 strings. Database ids never appear in output.
106
+
107
+ Related-intent links may form cycles; the store records what it is told and leaves the meaning to the reader. Bodies have no size cap.
108
+
109
+ ## Web GUI
110
+
111
+ `intent-record serve` binds to localhost only. The home page has one box: paste a commit hash, a ticket URL, or a few words, and it takes you to the right page. Commit pages list every intent for that hash; source pages list every intent and every commit for a ticket.
112
+
113
+ ## Agent integration
114
+
115
+ Any agent that can run a process and read stdout can use it. Record after each commit, `lookup` before touching unfamiliar code, `by-source` when picking up a ticket that has history.
116
+
117
+ For Claude Code there is a skill that says when to do each of those and what a usable `body` contains: [intent-record](https://github.com/beatmadsen/claude-skills/tree/main/skills/intent-record), in the [beatmadsen/claude-skills](https://github.com/beatmadsen/claude-skills) collection. Install it for every project you work on:
118
+
119
+ ```bash
120
+ git clone https://github.com/beatmadsen/claude-skills.git ~/claude-skills
121
+ ln -s ~/claude-skills/skills/intent-record ~/.claude/skills/intent-record
122
+ ```
123
+
124
+ Or copy `skills/intent-record` into a single project's `.claude/skills/` and commit it, so everyone working in that repository records the same way.
125
+
126
+ ## Development
127
+
128
+ ```bash
129
+ bin/setup
130
+ bundle exec rake # tests, then the same tests with unordered queries reversed, then rubocop
131
+ ```
132
+
133
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for the workflow and release steps, and [CHANGELOG.md](CHANGELOG.md) for what changed in each version.
134
+
135
+ ## License
136
+
137
+ [MIT](LICENSE.txt)
@@ -0,0 +1,68 @@
1
+ class CreateInitialSchema < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :vcs_systems do |t|
4
+ t.string :name, null: false
5
+ t.datetime :created_at, null: false
6
+ end
7
+ add_index :vcs_systems, :name, unique: true
8
+
9
+ create_table :asset_versions do |t|
10
+ t.references :vcs_system, null: false, foreign_key: true
11
+ t.string :external_id, null: false
12
+ t.datetime :created_at, null: false
13
+ end
14
+ add_index :asset_versions, %i[vcs_system_id external_id], unique: true
15
+ add_index :asset_versions, :external_id
16
+
17
+ create_table :intent_records do |t|
18
+ t.string :global_id, limit: 7, null: false
19
+ t.string :summary, null: false
20
+ t.text :body, null: false
21
+ t.string :author
22
+ t.datetime :created_at, null: false
23
+ end
24
+ add_index :intent_records, :global_id, unique: true
25
+ add_index :intent_records, :created_at
26
+
27
+ create_table :intent_record_asset_versions do |t|
28
+ t.references :intent_record, null: false, foreign_key: true
29
+ t.references :asset_version, null: false, foreign_key: true
30
+ t.datetime :created_at, null: false
31
+ end
32
+ add_index :intent_record_asset_versions, %i[intent_record_id asset_version_id],
33
+ unique: true, name: "idx_intent_asset_unique"
34
+
35
+ create_table :stakeholder_systems do |t|
36
+ t.string :name, null: false
37
+ t.datetime :created_at, null: false
38
+ end
39
+ add_index :stakeholder_systems, :name, unique: true
40
+
41
+ create_table :stakeholder_sources do |t|
42
+ t.references :stakeholder_system, null: false, foreign_key: true
43
+ t.text :uri, null: false
44
+ t.string :title
45
+ t.datetime :created_at, null: false
46
+ end
47
+ add_index :stakeholder_sources, %i[stakeholder_system_id uri], unique: true
48
+
49
+ create_table :stakeholder_references do |t|
50
+ t.references :intent_record, null: false, foreign_key: true
51
+ t.references :stakeholder_source, null: false, foreign_key: true
52
+ t.datetime :created_at, null: false
53
+ end
54
+ add_index :stakeholder_references, %i[intent_record_id stakeholder_source_id],
55
+ unique: true, name: "idx_stakeholder_ref_unique"
56
+
57
+ create_table :intent_record_links do |t|
58
+ t.bigint :source_intent_record_id, null: false
59
+ t.bigint :target_intent_record_id, null: false
60
+ t.datetime :created_at, null: false
61
+ end
62
+ add_index :intent_record_links, %i[source_intent_record_id target_intent_record_id],
63
+ unique: true, name: "idx_intent_link_unique"
64
+ add_index :intent_record_links, :target_intent_record_id
65
+ add_foreign_key :intent_record_links, :intent_records, column: :source_intent_record_id
66
+ add_foreign_key :intent_record_links, :intent_records, column: :target_intent_record_id
67
+ end
68
+ end
data/exe/intent-record ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "intent_record"
4
+
5
+ IntentRecord::CLI.run(ARGV)
@@ -0,0 +1,8 @@
1
+ require "active_record"
2
+
3
+ module IntentRecord
4
+ class ApplicationRecord < ActiveRecord::Base
5
+ self.abstract_class = true
6
+ self.table_name_prefix = ""
7
+ end
8
+ end
@@ -0,0 +1,43 @@
1
+ module IntentRecord
2
+ # Normalises external ids per VCS. Hash-based systems store their ids lowercased and
3
+ # support prefix lookup. Only git is checked for shape, and must be a full SHA-1 or
4
+ # SHA-256; the other hash-based systems take an id as given, so a prefix among those
5
+ # can collide, and the resolver reports that at lookup time rather than preventing it
6
+ # here.
7
+ module AssetVersionNormalizer
8
+ HASH_BASED = %w[git mercurial fossil sapling pijul darcs].freeze
9
+ GIT_HASH = /\A(?:[0-9a-f]{40}|[0-9a-f]{64})\z/
10
+
11
+ module_function
12
+
13
+ def vcs_name(vcs)
14
+ vcs.strip.downcase
15
+ end
16
+
17
+ def hash_based?(vcs)
18
+ HASH_BASED.include?(vcs)
19
+ end
20
+
21
+ def external_id(vcs, raw)
22
+ id = raw.strip
23
+ id = id.downcase if hash_based?(vcs)
24
+ if vcs == "git" && !GIT_HASH.match?(id)
25
+ raise ValidationError,
26
+ "git commit must be 40 or 64 hex characters, got #{raw.inspect}"
27
+ end
28
+
29
+ id
30
+ end
31
+
32
+ # Hash ids are canonically lowercase here, and that should not depend on a
33
+ # collation default we do not set. Nothing driving the CLI can tell this apart
34
+ # from leaving the id alone, because the resolver tries the id as written too
35
+ # and sqlite's LIKE folds ASCII case; the tests that decide it ask this
36
+ # directly, or go through the resolver below the prefix minimum, where there
37
+ # is no second chance from the prefix query.
38
+ def lookup_id(vcs, raw)
39
+ id = raw.strip
40
+ vcs.nil? || hash_based?(vcs) ? id.downcase : id
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,76 @@
1
+ require_relative "models/asset_version"
2
+ require_relative "models/vcs_system"
3
+ require_relative "asset_version_normalizer"
4
+ require_relative "like_pattern"
5
+
6
+ module IntentRecord
7
+ # Resolves an external id to one asset version. Exact match first; for hash-based VCSs a
8
+ # unique prefix of at least MIN_PREFIX_LENGTH characters also resolves.
9
+ class AssetVersionResolver
10
+ MIN_PREFIX_LENGTH = 4
11
+
12
+ def initialize(external_id:, vcs: nil)
13
+ @vcs = vcs && AssetVersionNormalizer.vcs_name(vcs)
14
+ @raw = external_id.strip
15
+ @external_id = AssetVersionNormalizer.lookup_id(@vcs, external_id)
16
+ end
17
+
18
+ def call
19
+ exact_match || resolve_prefix
20
+ end
21
+
22
+ private
23
+
24
+ # With no vcs given the id could belong to a hash-based system, where case is
25
+ # insignificant, or to a case-sensitive one, where it is not, so both spellings
26
+ # are candidates. When a vcs is given the normaliser has already picked the one
27
+ # that system uses.
28
+ def exact_candidates
29
+ @vcs ? [@external_id] : [@raw, @external_id].uniq
30
+ end
31
+
32
+ # Ids are unique per version control system, not across them, so one spelling
33
+ # can name two different things. Answering with either of them would be
34
+ # SQLite's choice, and it would hide everything recorded against the other,
35
+ # so say so instead and let the caller name the system.
36
+ def exact_match
37
+ exact_candidates.each do |id|
38
+ found = scope.where(external_id: id).to_a
39
+ next if found.empty?
40
+ raise ValidationError, ambiguous(id, found.size) if found.size > 1
41
+
42
+ return found.first
43
+ end
44
+ nil
45
+ end
46
+
47
+ def ambiguous(id, count)
48
+ "Ambiguous id #{id}: matches #{count} asset versions in different " \
49
+ "version control systems. Name one with --vcs."
50
+ end
51
+
52
+ def resolve_prefix
53
+ candidates = prefix_candidates
54
+ raise NotFoundError, "Asset version not found: #{@external_id}" if candidates.empty?
55
+ if candidates.size > 1
56
+ raise ValidationError,
57
+ "Ambiguous prefix #{@external_id}: matches #{candidates.size} asset versions"
58
+ end
59
+
60
+ candidates.first
61
+ end
62
+
63
+ def scope
64
+ base = Models::AssetVersion.includes(:vcs_system).joins(:vcs_system)
65
+ @vcs ? base.where(vcs_systems: { name: @vcs }) : base
66
+ end
67
+
68
+ def prefix_candidates
69
+ return [] if @external_id.length < MIN_PREFIX_LENGTH
70
+
71
+ scope.where(vcs_systems: { name: AssetVersionNormalizer::HASH_BASED })
72
+ .where(LikePattern.prefix("asset_versions.external_id"), LikePattern.prefix_bind(@external_id))
73
+ .to_a
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,78 @@
1
+ module IntentRecord
2
+ class CLI
3
+ # Owns the arguments it is given and consumes them: each `take_*` removes what
4
+ # it matched, so `reject_leftovers!` can then judge whatever nobody claimed.
5
+ # The caller's own array is never touched. Options may be given as
6
+ # `--name value` or `--name=value`.
7
+ class ArgvParser
8
+ def initialize(argv)
9
+ @argv = argv.dup
10
+ end
11
+
12
+ # What is left unconsumed, for a caller that forwards its arguments on
13
+ # rather than judging them here.
14
+ def remaining
15
+ @argv.dup
16
+ end
17
+
18
+ def take_flag(name)
19
+ indexes = @argv.each_index.select { |i| @argv[i] == name || @argv[i].start_with?("#{name}=") }
20
+ raise ValidationError, "#{name} given more than once" if indexes.size > 1
21
+ return nil if indexes.empty?
22
+
23
+ extract_flag(indexes.first, name)
24
+ end
25
+
26
+ def take_integer_flag(name, default)
27
+ raw = take_flag(name)
28
+ return default if raw.nil?
29
+ raise ValidationError, "#{name} requires an integer, got #{raw.inspect}" unless /\A\d+\z/.match?(raw)
30
+
31
+ raw.to_i
32
+ end
33
+
34
+ # Judged by its stripped form, so returned stripped too. Handing back the
35
+ # padded value let a command look an id up verbatim and miss a record that
36
+ # exists.
37
+ def take_required_positional(label)
38
+ value = @argv.shift
39
+ raise ValidationError, "#{label} is required" if value.nil? || value.strip.empty?
40
+
41
+ value.strip
42
+ end
43
+
44
+ # Everything that is not an option. The options are kept, so
45
+ # reject_leftovers! still judges them.
46
+ def take_positionals
47
+ options, positionals = @argv.partition { |token| token.start_with?("--") }
48
+ @argv.replace(options)
49
+ positionals
50
+ end
51
+
52
+ def take_switch?(name)
53
+ !@argv.delete(name).nil?
54
+ end
55
+
56
+ def reject_leftovers!
57
+ return if @argv.empty?
58
+
59
+ raise ValidationError, "Unexpected argument(s): #{@argv.join(" ")}"
60
+ end
61
+
62
+ private
63
+
64
+ # A flag whose value is missing is deliberately left in place rather than
65
+ # consumed, so that it reaches reject_leftovers! as an error instead of
66
+ # passing for a value nobody gave.
67
+ def extract_flag(idx, name)
68
+ token = @argv[idx]
69
+ return @argv.delete_at(idx).delete_prefix("#{name}=") if token.include?("=")
70
+ return nil unless @argv[idx + 1] && !@argv[idx + 1].start_with?("--")
71
+
72
+ value = @argv[idx + 1]
73
+ @argv.slice!(idx, 2)
74
+ value
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,118 @@
1
+ require "json"
2
+ require_relative "argv_parser"
3
+ require_relative "stdin_json"
4
+ require_relative "usage"
5
+ require_relative "../commands/record"
6
+ require_relative "../commands/show"
7
+ require_relative "../commands/lookup"
8
+ require_relative "../commands/search"
9
+ require_relative "../commands/by_source"
10
+ require_relative "../commands/recent"
11
+ require_relative "../commands/attach"
12
+ require_relative "../commands/systems"
13
+
14
+ module IntentRecord
15
+ class CLI
16
+ # Maps a command name to the command object that handles it and prints its JSON result.
17
+ # Each run_* parses argv fully, then `finish!` rejects anything left over before running.
18
+ class Dispatch
19
+ HANDLER_PREFIX = "run_".freeze
20
+
21
+ # The commands there are, taken from the handlers that implement them, so
22
+ # the list cannot fall behind what the dispatch actually answers to.
23
+ def self.commands
24
+ handlers = private_instance_methods(false).grep(/\A#{HANDLER_PREFIX}/)
25
+ handlers.map { |name| name.to_s.delete_prefix(HANDLER_PREFIX).tr("_", "-") }.sort
26
+ end
27
+
28
+ def initialize(argv, streams:, config:)
29
+ @parser = ArgvParser.new(argv)
30
+ @streams = streams
31
+ @config = config
32
+ end
33
+
34
+ def call(command)
35
+ handler = "#{HANDLER_PREFIX}#{command.to_s.tr("-", "_")}"
36
+ raise ValidationError, unknown(command) unless respond_to?(handler, true)
37
+
38
+ emit(send(handler))
39
+ end
40
+
41
+ private
42
+
43
+ # An option in the command's place is almost always a misspelled one, and
44
+ # calling it an unknown command sends the reader looking for a command they
45
+ # never meant to type.
46
+ def unknown(command)
47
+ return "Unknown option: #{command}" if command.to_s.start_with?("--")
48
+
49
+ "Unknown command: #{command}. Known commands: #{self.class.commands.join(", ")}"
50
+ end
51
+
52
+ def emit(result)
53
+ @streams.stdout.puts JSON.generate(result)
54
+ 0
55
+ end
56
+
57
+ def finish!
58
+ @parser.reject_leftovers!
59
+ yield
60
+ end
61
+
62
+ def stdin_json
63
+ StdinJson.read(@streams.stdin)
64
+ end
65
+
66
+ def positional(label)
67
+ @parser.take_required_positional(label)
68
+ end
69
+
70
+ def run_record
71
+ finish! { Commands::Record.new.call(stdin_json) }
72
+ end
73
+
74
+ def run_show
75
+ command = Commands::Show.new(intent_id: positional("intent_id"))
76
+ finish! { command.call }
77
+ end
78
+
79
+ def run_lookup
80
+ vcs = @parser.take_flag("--vcs")
81
+ command = Commands::Lookup.new(external_id: positional("external_id"), vcs: vcs)
82
+ finish! { command.call }
83
+ end
84
+
85
+ def run_search
86
+ match = @parser.take_flag("--match") || "any"
87
+ terms = @parser.take_positionals
88
+ finish! { Commands::Search.new(terms: terms, match: match).call }
89
+ end
90
+
91
+ def run_by_source
92
+ contains = @parser.take_switch?("--contains")
93
+ command = Commands::BySource.new(uri: positional("uri"), contains: contains)
94
+ finish! { command.call }
95
+ end
96
+
97
+ def run_recent
98
+ limit = @parser.take_integer_flag("--limit", Commands::Recent::DEFAULT_LIMIT)
99
+ finish! { Commands::Recent.new(limit: limit).call }
100
+ end
101
+
102
+ def run_attach
103
+ command = Commands::Attach.new(intent_id: positional("intent_id"))
104
+ finish! { command.call(stdin_json) }
105
+ end
106
+
107
+ def run_systems
108
+ finish! { Commands::Systems.new.call }
109
+ end
110
+
111
+ def run_serve
112
+ require_relative "../web/boot"
113
+ Web::Boot.run!(config: @config, argv: @parser.remaining, streams: @streams)
114
+ {}
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,28 @@
1
+ require "json"
2
+
3
+ module IntentRecord
4
+ class CLI
5
+ # The JSON object an agent sends on stdin. Nothing here concerns argv, which
6
+ # is why it is not in ArgvParser.
7
+ module StdinJson
8
+ module_function
9
+
10
+ def read(stdin)
11
+ raw = stdin.read.to_s.dup.force_encoding(Encoding::UTF_8)
12
+ raise ValidationError, "Input is not valid UTF-8" unless raw.valid_encoding?
13
+ raise ValidationError, "Empty input" if raw.strip.empty?
14
+
15
+ parse_object(raw)
16
+ end
17
+
18
+ def parse_object(raw)
19
+ parsed = JSON.parse(raw)
20
+ raise ValidationError, "Input must be a JSON object" unless parsed.is_a?(Hash)
21
+
22
+ parsed
23
+ rescue JSON::ParserError => e
24
+ raise ValidationError, "Invalid JSON input: #{e.message}"
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,9 @@
1
+ module IntentRecord
2
+ class CLI
3
+ Streams = Struct.new(:stdin, :stdout, :stderr, keyword_init: true) do
4
+ def self.default
5
+ new(stdin: $stdin, stdout: $stdout, stderr: $stderr)
6
+ end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,29 @@
1
+ module IntentRecord
2
+ class CLI
3
+ USAGE = <<~USAGE.freeze
4
+ Usage: intent-record <command> [options]
5
+
6
+ Commands:
7
+ record Record the intent behind a change (JSON via stdin)
8
+ attach <intent_id> Link more commits / stakeholder refs / related intents (JSON via stdin)
9
+ show <intent_id> Full intent record
10
+ lookup <commit> [--vcs name] Intents recorded against a commit (full hash or unique prefix)
11
+ search <terms...> [--match all] Substring search over summary and body
12
+ by-source <uri> [--contains] Intents linked to a stakeholder uri (Jira ticket, Confluence page, ...)
13
+ recent [--limit N] Newest intents first
14
+ systems Known vcs and stakeholder system names
15
+ serve [--port N] Start the local web GUI
16
+
17
+ Options:
18
+ --config-dir <dir> Use the store in this directory instead of ~/.intent-record
19
+ --help, -h Show this help
20
+ --version Show version
21
+
22
+ Input shape for record/attach (all keys optional except summary and body on record):
23
+ {"summary": "...", "body": "...", "author": "...",
24
+ "commits": ["<git hash>"], "asset_versions": [{"vcs": "perforce", "external_id": "123"}],
25
+ "stakeholder_references": [{"system": "jira", "uri": "https://...", "title": "..."}],
26
+ "related_intent_ids": ["<intent_id>"]}
27
+ USAGE
28
+ end
29
+ end