textus 0.43.2 → 0.46.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 +4 -4
- data/CHANGELOG.md +70 -0
- data/README.md +56 -29
- data/SPEC.md +24 -22
- data/docs/architecture/README.md +32 -32
- data/docs/reference/conventions.md +8 -9
- data/lib/textus/boot.rb +4 -4
- data/lib/textus/builder/pipeline.rb +11 -42
- data/lib/textus/builder/renderer/markdown.rb +4 -8
- data/lib/textus/cli/group/fetch.rb +2 -2
- data/lib/textus/cli/group.rb +1 -0
- data/lib/textus/cli/runner.rb +187 -0
- data/lib/textus/cli/verb/build.rb +4 -4
- data/lib/textus/cli/verb/{fetch_stale.rb → fetch_all.rb} +2 -2
- data/lib/textus/cli/verb/get.rb +6 -5
- data/lib/textus/cli/verb/put.rb +3 -3
- data/lib/textus/cli/verb.rb +3 -0
- data/lib/textus/cli.rb +37 -3
- data/lib/textus/container.rb +3 -15
- data/lib/textus/contract/around.rb +29 -0
- data/lib/textus/contract/binder.rb +88 -0
- data/lib/textus/contract/resources/cursor.rb +26 -0
- data/lib/textus/contract/sources.rb +39 -0
- data/lib/textus/contract/view.rb +15 -0
- data/lib/textus/contract.rb +68 -8
- data/lib/textus/dispatcher.rb +6 -6
- data/lib/textus/doctor/check/orphaned_publish_targets.rb +1 -1
- data/lib/textus/doctor/check/sentinels.rb +1 -1
- data/lib/textus/domain/policy/predicates/fresh_within.rb +6 -5
- data/lib/textus/envelope/io/writer.rb +34 -0
- data/lib/textus/hooks/context.rb +24 -2
- data/lib/textus/layout.rb +8 -0
- data/lib/textus/maintenance/key_delete_prefix.rb +8 -5
- data/lib/textus/maintenance/key_mv_prefix.rb +18 -6
- data/lib/textus/maintenance/migrate.rb +14 -10
- data/lib/textus/maintenance/rule_lint.rb +5 -4
- data/lib/textus/maintenance/zone_mv.rb +9 -6
- data/lib/textus/manifest/entry/base.rb +1 -1
- data/lib/textus/mcp/catalog.rb +6 -33
- data/lib/textus/ports/publisher.rb +3 -2
- data/lib/textus/ports/sentinel_store.rb +8 -7
- data/lib/textus/projection.rb +6 -5
- data/lib/textus/read/audit.rb +19 -0
- data/lib/textus/read/blame.rb +11 -1
- data/lib/textus/read/boot.rb +1 -1
- data/lib/textus/read/capabilities.rb +70 -0
- data/lib/textus/read/deps.rb +15 -1
- data/lib/textus/read/doctor.rb +8 -0
- data/lib/textus/read/freshness.rb +10 -0
- data/lib/textus/read/get.rb +87 -22
- data/lib/textus/read/list.rb +2 -1
- data/lib/textus/read/published.rb +7 -0
- data/lib/textus/read/pulse.rb +2 -1
- data/lib/textus/read/rdeps.rb +14 -0
- data/lib/textus/read/rule_explain.rb +84 -0
- data/lib/textus/read/rule_list.rb +39 -0
- data/lib/textus/read/schema_envelope.rb +3 -2
- data/lib/textus/read/uid.rb +9 -0
- data/lib/textus/read/where.rb +8 -0
- data/lib/textus/role_scope.rb +34 -6
- data/lib/textus/schema/tools.rb +12 -3
- data/lib/textus/store.rb +47 -24
- data/lib/textus/version.rb +1 -1
- data/lib/textus/write/accept.rb +8 -0
- data/lib/textus/write/{publish.rb → build.rb} +16 -7
- data/lib/textus/write/delete.rb +13 -0
- data/lib/textus/write/fetch_all.rb +2 -1
- data/lib/textus/write/fetch_orchestrator.rb +1 -1
- data/lib/textus/write/fetch_worker.rb +2 -2
- data/lib/textus/write/mv.rb +16 -0
- data/lib/textus/write/propose.rb +8 -3
- data/lib/textus/write/put.rb +3 -3
- data/lib/textus/write/reject.rb +8 -0
- data/lib/textus/write/retention_sweep.rb +9 -0
- metadata +12 -29
- data/lib/textus/cli/verb/accept.rb +0 -16
- data/lib/textus/cli/verb/audit.rb +0 -34
- data/lib/textus/cli/verb/blame.rb +0 -17
- data/lib/textus/cli/verb/delete.rb +0 -17
- data/lib/textus/cli/verb/deps.rb +0 -14
- data/lib/textus/cli/verb/freshness.rb +0 -17
- data/lib/textus/cli/verb/key_delete.rb +0 -24
- data/lib/textus/cli/verb/list.rb +0 -16
- data/lib/textus/cli/verb/migrate.rb +0 -18
- data/lib/textus/cli/verb/mv.rb +0 -27
- data/lib/textus/cli/verb/propose.rb +0 -28
- data/lib/textus/cli/verb/published.rb +0 -13
- data/lib/textus/cli/verb/pulse.rb +0 -26
- data/lib/textus/cli/verb/rdeps.rb +0 -14
- data/lib/textus/cli/verb/reject.rb +0 -16
- data/lib/textus/cli/verb/retain.rb +0 -19
- data/lib/textus/cli/verb/rule_explain.rb +0 -16
- data/lib/textus/cli/verb/rule_lint.rb +0 -18
- data/lib/textus/cli/verb/rule_list.rb +0 -29
- data/lib/textus/cli/verb/schema.rb +0 -15
- data/lib/textus/cli/verb/uid.rb +0 -15
- data/lib/textus/cli/verb/where.rb +0 -14
- data/lib/textus/cli/verb/zone_mv.rb +0 -19
- data/lib/textus/read/get_or_fetch.rb +0 -69
- data/lib/textus/read/policy_explain.rb +0 -46
- data/lib/textus/read/rules.rb +0 -25
data/lib/textus/boot.rb
CHANGED
|
@@ -81,12 +81,11 @@ module Textus
|
|
|
81
81
|
{ "name" => "list" },
|
|
82
82
|
{ "name" => "get" },
|
|
83
83
|
{ "name" => "where", "summary" => "resolve a key to its zone and path without reading" },
|
|
84
|
-
{ "name" => "schema" },
|
|
84
|
+
{ "name" => "schema", "summary" => "schema operations: 'schema show KEY', 'schema diff', 'schema init', 'schema migrate'" },
|
|
85
85
|
{ "name" => "put" },
|
|
86
86
|
{ "name" => "propose" },
|
|
87
87
|
{ "name" => "accept", "summary" => "apply a queued proposal to its target zone; requires the author capability" },
|
|
88
|
-
{ "name" => "key", "summary" => "key operations: 'key mv', 'key uid'" },
|
|
89
|
-
{ "name" => "delete", "summary" => "delete an entry; --as=<role>" },
|
|
88
|
+
{ "name" => "key", "summary" => "key operations: 'key delete', 'key mv', 'key uid'" },
|
|
90
89
|
{ "name" => "build", "summary" => "materialize derived entries; publish_to and publish_tree fan out copies" },
|
|
91
90
|
{ "name" => "fetch" },
|
|
92
91
|
{ "name" => "freshness", "summary" => "per-entry freshness report (status, age, ttl, on_stale)" },
|
|
@@ -96,6 +95,7 @@ module Textus
|
|
|
96
95
|
{ "name" => "doctor", "summary" => "health-check the store (missing schemas, illegal keys, sentinel drift, etc.)" },
|
|
97
96
|
{ "name" => "hook", "summary" => "list and run registered hooks: 'hook list', 'hook run NAME'" },
|
|
98
97
|
{ "name" => "pulse" },
|
|
98
|
+
{ "name" => "capabilities" },
|
|
99
99
|
].freeze
|
|
100
100
|
|
|
101
101
|
# Build the CLI verb catalog by deriving each summary from the corresponding
|
|
@@ -131,7 +131,7 @@ module Textus
|
|
|
131
131
|
# agent's real read and write surface, named as verbs the agent calls —
|
|
132
132
|
# not CLI strings. read_verbs can neither advertise a verb the agent
|
|
133
133
|
# cannot call (audit/freshness/doctor are CLI-only) nor omit one it can
|
|
134
|
-
# (
|
|
134
|
+
# (schema_show/rules); write_verbs drops the old `put KEY --as=… --stdin` CLI
|
|
135
135
|
# framing (role is connection-resolved over MCP; there is no stdin).
|
|
136
136
|
# writable_zones / propose_zone below carry the agent's write authority.
|
|
137
137
|
"read_verbs" => Textus::MCP::Catalog.read_verbs,
|
|
@@ -5,8 +5,12 @@ module Textus
|
|
|
5
5
|
module Builder
|
|
6
6
|
module InjectMeta
|
|
7
7
|
# Returns a new hash with _meta as the first key, per SPEC §6 ordering.
|
|
8
|
+
# Carries only deterministic provenance (`from`/`reduce`/`template`) — the
|
|
9
|
+
# volatile `generated_at` is deliberately NOT stamped, so the built
|
|
10
|
+
# artifact is content-addressed and a rebuild is a byte-for-byte no-op
|
|
11
|
+
# (ADR 0070). Build time lives out of the tracked artifact.
|
|
8
12
|
def self.call(content_hash, mentry)
|
|
9
|
-
meta = {
|
|
13
|
+
meta = {}
|
|
10
14
|
if mentry.is_a?(Textus::Manifest::Entry::Derived)
|
|
11
15
|
src = mentry.source
|
|
12
16
|
if src.is_a?(Textus::Manifest::Entry::Derived::Projection)
|
|
@@ -23,35 +27,6 @@ module Textus
|
|
|
23
27
|
end
|
|
24
28
|
end
|
|
25
29
|
|
|
26
|
-
# Replaces the freshly-stamped timestamp inside `new_bytes` with the
|
|
27
|
-
# timestamp pulled from `old_bytes` (same format). Returns the rewritten
|
|
28
|
-
# bytes, or nil if either side lacks a parseable timestamp.
|
|
29
|
-
module IdempotentWrite
|
|
30
|
-
def self.rewrite_with_prior_timestamp(new_bytes:, old_bytes:, format:)
|
|
31
|
-
prior = extract_timestamp(old_bytes, format)
|
|
32
|
-
fresh = extract_timestamp(new_bytes, format)
|
|
33
|
-
return nil unless prior && fresh
|
|
34
|
-
return new_bytes if prior == fresh
|
|
35
|
-
|
|
36
|
-
new_bytes.sub(fresh, prior)
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
def self.extract_timestamp(bytes, format)
|
|
40
|
-
case format
|
|
41
|
-
when "markdown"
|
|
42
|
-
parsed = Entry.for_format("markdown").parse(bytes)
|
|
43
|
-
parsed.dig("_meta", "generated", "at")
|
|
44
|
-
when "json", "yaml"
|
|
45
|
-
parsed = Entry.for_format(format).parse(bytes)
|
|
46
|
-
parsed.dig("_meta", "generated_at")
|
|
47
|
-
else # rubocop:disable Style/EmptyElse
|
|
48
|
-
nil
|
|
49
|
-
end
|
|
50
|
-
rescue Textus::BadFrontmatter
|
|
51
|
-
nil
|
|
52
|
-
end
|
|
53
|
-
end
|
|
54
|
-
|
|
55
30
|
module Pipeline
|
|
56
31
|
Deps = Data.define(
|
|
57
32
|
:manifest, :reader, :lister, :rpc, :template_loader, :transform_context, :inject_boot
|
|
@@ -95,18 +70,12 @@ module Textus
|
|
|
95
70
|
target_path
|
|
96
71
|
end
|
|
97
72
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
rewritten = IdempotentWrite.rewrite_with_prior_timestamp(
|
|
105
|
-
new_bytes: bytes, old_bytes: old_bytes, format: format,
|
|
106
|
-
)
|
|
107
|
-
return if rewritten && rewritten == old_bytes
|
|
108
|
-
end
|
|
109
|
-
end
|
|
73
|
+
# Built artifacts are content-addressed (no volatile timestamp, ADR 0070),
|
|
74
|
+
# so identity is plain byte-equality: skip the write when nothing changed.
|
|
75
|
+
# `format` is retained for signature stability across renderers.
|
|
76
|
+
def self.write_if_changed(target_path, bytes, _format)
|
|
77
|
+
return if File.exist?(target_path) && File.binread(target_path) == bytes
|
|
78
|
+
|
|
110
79
|
File.binwrite(target_path, bytes)
|
|
111
80
|
end
|
|
112
81
|
end
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
require "time"
|
|
2
|
-
|
|
3
1
|
module Textus
|
|
4
2
|
module Builder
|
|
5
3
|
class Renderer
|
|
@@ -14,12 +12,10 @@ module Textus
|
|
|
14
12
|
else
|
|
15
13
|
[]
|
|
16
14
|
end
|
|
17
|
-
frontmatter
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
},
|
|
22
|
-
}
|
|
15
|
+
# Deterministic frontmatter only — `from` (the source keys), never a
|
|
16
|
+
# volatile `generated.at` (ADR 0070): the artifact is content-addressed
|
|
17
|
+
# so a rebuild is a byte-for-byte no-op and a revert never drifts.
|
|
18
|
+
frontmatter = { "generated" => { "from" => from } }
|
|
23
19
|
Entry.for_format("markdown").serialize(meta: frontmatter, body: body)
|
|
24
20
|
end
|
|
25
21
|
end
|
data/lib/textus/cli/group.rb
CHANGED
|
@@ -6,6 +6,7 @@ module Textus
|
|
|
6
6
|
# `parent_group` is this group counts as a subcommand. Sorted
|
|
7
7
|
# alphabetically by command_name for stable help output.
|
|
8
8
|
def subcommands
|
|
9
|
+
Textus::CLI::Runner.install!
|
|
9
10
|
Verb.descendants
|
|
10
11
|
.select { |k| k.parent_group == self && k.command_name }
|
|
11
12
|
.sort_by(&:command_name)
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class CLI
|
|
3
|
+
# Generates CLI::Verb (and CLI::Group) subclasses from per-verb contracts,
|
|
4
|
+
# so the CLI surface is a projection of the contract — the operator-facing
|
|
5
|
+
# mirror of MCP::Catalog (ADR 0063).
|
|
6
|
+
module Runner
|
|
7
|
+
# Subclassable base for contract-projected verbs. Carries the verb's
|
|
8
|
+
# contract (class attr `spec`) and the generic dispatch, exposing one
|
|
9
|
+
# overridable seam, #invoke, that defaults to the generic projection.
|
|
10
|
+
# Escape-hatch verbs subclass this and override #invoke to add behavior
|
|
11
|
+
# (suggestions, --stdin, BuildLock, multi-dispatch) WITHOUT restating the
|
|
12
|
+
# verb name — `spec.verb` remains the single source of dispatch.
|
|
13
|
+
class Base < Verb
|
|
14
|
+
class << self
|
|
15
|
+
attr_accessor :spec
|
|
16
|
+
|
|
17
|
+
# ADR 0064: derive the CLI command name from the contract's cli_leaf
|
|
18
|
+
# when not set explicitly, so an escape-hatch class never restates its
|
|
19
|
+
# own name. The reconciliation spec proves command_name == cli_leaf for
|
|
20
|
+
# every such class, so this is an equivalence, not a behavior change.
|
|
21
|
+
def command_name(name = nil)
|
|
22
|
+
return super if name
|
|
23
|
+
|
|
24
|
+
super() || spec&.cli_leaf
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def spec = self.class.spec
|
|
29
|
+
|
|
30
|
+
def call(store)
|
|
31
|
+
invoke(store)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Default: pure contract projection. Override in subclasses for behavior.
|
|
35
|
+
def invoke(store)
|
|
36
|
+
Runner.dispatch(self, store, spec)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def flag_values(s = spec)
|
|
40
|
+
s.args.reject(&:positional).each_with_object({}) do |a, h|
|
|
41
|
+
raw = respond_to?(a.name) ? public_send(a.name) : nil
|
|
42
|
+
next if raw.nil?
|
|
43
|
+
|
|
44
|
+
h[a.name] = Runner.coerce(a, raw)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
module_function
|
|
50
|
+
|
|
51
|
+
# Normalize parsed CLI input into the uniform by-name inputs hash and
|
|
52
|
+
# dispatch through RoleScope's single bind+invoke site. A missing required
|
|
53
|
+
# arg becomes a UsageError phrased in the operator's command path (parity
|
|
54
|
+
# with the hand-written verbs).
|
|
55
|
+
def dispatch(verb_instance, store, spec)
|
|
56
|
+
inputs = Textus::Contract::Binder.inputs_from_ordered(
|
|
57
|
+
spec, verb_instance.positional, verb_instance.flag_values(spec)
|
|
58
|
+
)
|
|
59
|
+
inputs = inputs.merge(Textus::Contract::Sources.from_stdin(spec, verb_instance.stdin)) if spec.cli_stdin
|
|
60
|
+
inputs = Textus::Contract::Sources.acquire(spec, inputs)
|
|
61
|
+
inputs = apply_cli_defaults(spec, inputs)
|
|
62
|
+
scope = verb_instance.session_for(store)
|
|
63
|
+
begin
|
|
64
|
+
result = scope.dispatch_bound(spec.verb, inputs)
|
|
65
|
+
rescue Textus::Contract::MissingArgs => e
|
|
66
|
+
raise UsageError.new("#{spec.cli_path} requires #{e.missing.first.wire}")
|
|
67
|
+
end
|
|
68
|
+
verb_instance.emit(shape(spec, result, inputs))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Fill CLI-specific defaults (cli_default:) for args the operator did not
|
|
72
|
+
# pass, where the CLI default diverges from the contract default the agent
|
|
73
|
+
# surfaces use — e.g. migrate/zone_mv apply by default on the CLI but plan
|
|
74
|
+
# by default for agents (ADR 0068). The divergence is legible in the
|
|
75
|
+
# contract, not hidden in a hand class.
|
|
76
|
+
def apply_cli_defaults(spec, inputs)
|
|
77
|
+
spec.args.each_with_object(inputs.dup) do |a, h|
|
|
78
|
+
next if a.cli_default == :__unset || h.key?(a.name)
|
|
79
|
+
|
|
80
|
+
h[a.name] = a.cli_default
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Shape the use-case result for the CLI wire via the verb's :cli view
|
|
85
|
+
# (falling back to the default view). The view is called uniformly as
|
|
86
|
+
# (result, inputs); an inputs-aware view echoes an input such as the key
|
|
87
|
+
# (ADR 0067).
|
|
88
|
+
def shape(spec, result, inputs)
|
|
89
|
+
Textus::Contract::View.render(spec, :cli, result, inputs)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# The default the CLI flag is generated against — `cli_default:` when the
|
|
93
|
+
# operator-facing default diverges from the contract default the agent
|
|
94
|
+
# surfaces use, else the contract `default`. This drives boolean flag
|
|
95
|
+
# polarity so a verb that applies-by-default on the CLI but plans-by-default
|
|
96
|
+
# for agents (migrate, zone_mv) gets a `--dry-run` flag, not `--no-dry-run`.
|
|
97
|
+
def effective_default(arg)
|
|
98
|
+
arg.cli_default == :__unset ? arg.default : arg.cli_default
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def flagspec_for(arg)
|
|
102
|
+
wire = arg.wire.to_s.tr("_", "-")
|
|
103
|
+
if arg.type == :boolean
|
|
104
|
+
effective_default(arg) == true ? "--no-#{wire}" : "--#{wire}"
|
|
105
|
+
else
|
|
106
|
+
"--#{wire}=VALUE"
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# NB: compare arg.type by equality, not `case`/`===` — `Integer === arg.type`
|
|
111
|
+
# is false when arg.type is the Integer *class* (it tests instance-of), so a
|
|
112
|
+
# `when Integer` branch would silently never coerce.
|
|
113
|
+
def coerce(arg, raw)
|
|
114
|
+
return effective_default(arg) != true if arg.type == :boolean
|
|
115
|
+
return Integer(raw) if arg.type == Integer
|
|
116
|
+
|
|
117
|
+
raw
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def ensure_group(name)
|
|
121
|
+
const = name.split("_").map(&:capitalize).join
|
|
122
|
+
return Group.const_get(const, false) if Group.const_defined?(const, false)
|
|
123
|
+
|
|
124
|
+
g = Class.new(Group) { command_name name }
|
|
125
|
+
Group.const_set(const, g)
|
|
126
|
+
g
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Contract verbs whose CLI behavior is a genuine `< Runner::Base` override
|
|
130
|
+
# — behavior the generic projection cannot express (ADR 0068/0069):
|
|
131
|
+
# get — raises UnknownKey with resolver suggestions (a CLI-only
|
|
132
|
+
# affordance; the agent surface deliberately returns nil)
|
|
133
|
+
# put — IntakeFetch read-through orchestration on --fetch
|
|
134
|
+
# build — auto-resolves the build-capability actor role (not --as) and
|
|
135
|
+
# serializes under BuildLock; the role resolution is policy, not
|
|
136
|
+
# a projection (around: covers only the lock)
|
|
137
|
+
BEHAVIORAL_HATCHES = %i[get put build].freeze
|
|
138
|
+
|
|
139
|
+
# Contract verbs whose CLI is a plain `< Verb` command, not a projection at
|
|
140
|
+
# all — worker verbs and composite reports assembled outside the contract:
|
|
141
|
+
# fetch, fetch_all — background intake workers (not request/response)
|
|
142
|
+
# boot, doctor — composite reports
|
|
143
|
+
NON_PROJECTED_CLI = %i[fetch fetch_all boot doctor].freeze
|
|
144
|
+
|
|
145
|
+
# The installer skips generation for either category.
|
|
146
|
+
HAND_AUTHORED_VERBS = (BEHAVIORAL_HATCHES + NON_PROJECTED_CLI).freeze
|
|
147
|
+
|
|
148
|
+
def hand_authored?(verb) = HAND_AUTHORED_VERBS.include?(verb)
|
|
149
|
+
|
|
150
|
+
def install!
|
|
151
|
+
@installed ||= {}
|
|
152
|
+
Textus::Dispatcher::VERBS.each_value do |klass|
|
|
153
|
+
next unless klass.respond_to?(:contract?) && klass.contract?
|
|
154
|
+
|
|
155
|
+
spec = klass.contract
|
|
156
|
+
next unless spec.cli?
|
|
157
|
+
next if hand_authored?(spec.verb)
|
|
158
|
+
next if @installed[spec.verb]
|
|
159
|
+
|
|
160
|
+
install_for(spec)
|
|
161
|
+
@installed[spec.verb] = true
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def install_for(spec)
|
|
166
|
+
group = spec.cli_group ? ensure_group(spec.cli_group) : nil
|
|
167
|
+
leaf = spec.cli_leaf
|
|
168
|
+
non_positional = spec.args.reject(&:positional)
|
|
169
|
+
|
|
170
|
+
klass = Class.new(Base)
|
|
171
|
+
klass.spec = spec
|
|
172
|
+
klass.command_name leaf
|
|
173
|
+
klass.parent_group group if group
|
|
174
|
+
klass.option :as_flag, "--as=ROLE"
|
|
175
|
+
klass.option :use_stdin, "--stdin" if spec.cli_stdin
|
|
176
|
+
non_positional.each { |a| klass.option a.name, Runner.flagspec_for(a) }
|
|
177
|
+
|
|
178
|
+
# Anchor the anonymous class to a constant so descendants discovery is
|
|
179
|
+
# stable. Name it after the verb under a Generated namespace.
|
|
180
|
+
const_name = spec.verb.to_s.split("_").map(&:capitalize).join
|
|
181
|
+
gen = "Gen#{const_name}"
|
|
182
|
+
Verb.const_set(gen, klass) unless Verb.const_defined?(gen, false)
|
|
183
|
+
klass
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
class CLI
|
|
3
3
|
class Verb
|
|
4
|
-
class Build <
|
|
5
|
-
|
|
4
|
+
class Build < Runner::Base
|
|
5
|
+
self.spec = Textus::Write::Build.contract
|
|
6
6
|
|
|
7
7
|
option :prefix, "--prefix=K"
|
|
8
8
|
|
|
9
|
-
def
|
|
9
|
+
def invoke(store)
|
|
10
10
|
role = store.manifest.policy.actor_for("build") or
|
|
11
11
|
raise UsageError.new(
|
|
12
12
|
"no role holds the 'build' capability",
|
|
@@ -14,7 +14,7 @@ module Textus
|
|
|
14
14
|
)
|
|
15
15
|
Textus::Ports::BuildLock.with(root: store.root) do
|
|
16
16
|
ops = store.as(role)
|
|
17
|
-
result = ops.
|
|
17
|
+
result = ops.build(prefix: prefix)
|
|
18
18
|
emit(result)
|
|
19
19
|
end
|
|
20
20
|
end
|
data/lib/textus/cli/verb/get.rb
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
class CLI
|
|
3
3
|
class Verb
|
|
4
|
-
class Get <
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
class Get < Runner::Base
|
|
5
|
+
self.spec = Textus::Read::Get.contract
|
|
7
6
|
option :as_flag, "--as=ROLE"
|
|
7
|
+
option :no_fetch, "--no-fetch"
|
|
8
8
|
|
|
9
|
-
def
|
|
9
|
+
def invoke(store)
|
|
10
10
|
key = positional.shift or raise UsageError.new("get requires a key")
|
|
11
|
-
|
|
11
|
+
kw = no_fetch.nil? ? {} : { fetch: false }
|
|
12
|
+
result = session_for(store).get(key, **kw)
|
|
12
13
|
raise Textus::UnknownKey.new(key, suggestions: store.manifest.resolver.suggestions_for(key)) if result.nil?
|
|
13
14
|
|
|
14
15
|
emit(result.to_h_for_wire)
|
data/lib/textus/cli/verb/put.rb
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
class CLI
|
|
3
3
|
class Verb
|
|
4
|
-
class Put <
|
|
5
|
-
|
|
4
|
+
class Put < Runner::Base
|
|
5
|
+
self.spec = Textus::Write::Put.contract
|
|
6
6
|
|
|
7
7
|
option :as_flag, "--as=ROLE"
|
|
8
8
|
option :use_stdin, "--stdin"
|
|
9
9
|
option :fetch_name, "--fetch=NAME"
|
|
10
10
|
|
|
11
|
-
def
|
|
11
|
+
def invoke(store)
|
|
12
12
|
key = positional.shift or raise UsageError.new("put requires a key")
|
|
13
13
|
raise UsageError.new("put requires --stdin in v1") unless use_stdin
|
|
14
14
|
|
data/lib/textus/cli/verb.rb
CHANGED
data/lib/textus/cli.rb
CHANGED
|
@@ -7,9 +7,15 @@ module Textus
|
|
|
7
7
|
# declares `command_name "X"` and has no `parent_group` is a top-level
|
|
8
8
|
# verb. Sorted alphabetically for stable help output. Adding a new
|
|
9
9
|
# verb requires only a new file declaring its `command_name`.
|
|
10
|
+
#
|
|
11
|
+
# `k.name` gates out anonymous (Class.new) subclasses: real verbs are always
|
|
12
|
+
# named constants (generated Gen* or hand-authored classes), so this is a
|
|
13
|
+
# no-op in production but keeps throwaway test fixtures from leaking into the
|
|
14
|
+
# registry (and tripping the reconciliation guards order-dependently).
|
|
10
15
|
def self.verbs
|
|
16
|
+
Runner.install!
|
|
11
17
|
Verb.descendants
|
|
12
|
-
.select { |k| k.command_name && k.parent_group.nil? }
|
|
18
|
+
.select { |k| k.name && k.command_name && k.parent_group.nil? }
|
|
13
19
|
.sort_by(&:command_name)
|
|
14
20
|
.to_h { |k| [k.command_name, k] }
|
|
15
21
|
end
|
|
@@ -27,13 +33,21 @@ module Textus
|
|
|
27
33
|
end
|
|
28
34
|
|
|
29
35
|
def run(argv)
|
|
36
|
+
# `--root` is a global, position-agnostic option: pull it out of argv
|
|
37
|
+
# wherever it appears so it works uniformly before OR after any verb or
|
|
38
|
+
# group (e.g. both `textus --root=X hook list` and
|
|
39
|
+
# `textus hook list --root=X`). Without this, `order!` below only sees
|
|
40
|
+
# options before the first verb token, so a trailing `--root` reached the
|
|
41
|
+
# verb's own parser and raised InvalidOption (#161 F5). TEXTUS_ROOT already
|
|
42
|
+
# works everywhere via Store.discover, so this brings the flag to parity.
|
|
43
|
+
@root_arg = extract_root!(argv)
|
|
44
|
+
|
|
30
45
|
# Define --version/--help ourselves so OptionParser doesn't intercept them
|
|
31
46
|
# with its built-in handlers (which print "version unknown" and a bare usage
|
|
32
47
|
# line, then exit before we ever reach the verb dispatch below).
|
|
33
48
|
show_version = false
|
|
34
49
|
show_help = false
|
|
35
50
|
OptionParser.new do |o|
|
|
36
|
-
o.on("--root=PATH") { |v| @root_arg = v }
|
|
37
51
|
o.on("--version", "-v") { show_version = true }
|
|
38
52
|
o.on("--help", "-h") { show_help = true }
|
|
39
53
|
end.order!(argv)
|
|
@@ -52,6 +66,26 @@ module Textus
|
|
|
52
66
|
|
|
53
67
|
private
|
|
54
68
|
|
|
69
|
+
# Remove the first `--root=PATH` or `--root PATH` token from argv (anywhere)
|
|
70
|
+
# and return its value, or nil if absent. Mutates argv in place.
|
|
71
|
+
def extract_root!(argv)
|
|
72
|
+
i = argv.index { |a| a == "--root" || a.start_with?("--root=") }
|
|
73
|
+
return nil unless i
|
|
74
|
+
|
|
75
|
+
tok = argv[i]
|
|
76
|
+
if tok.start_with?("--root=")
|
|
77
|
+
argv.delete_at(i)
|
|
78
|
+
tok.delete_prefix("--root=")
|
|
79
|
+
else
|
|
80
|
+
val = argv[i + 1]
|
|
81
|
+
raise UsageError.new("--root requires a PATH") if val.nil? || val.start_with?("-")
|
|
82
|
+
|
|
83
|
+
argv.delete_at(i + 1)
|
|
84
|
+
argv.delete_at(i)
|
|
85
|
+
val
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
55
89
|
def coerce_exit_code(value)
|
|
56
90
|
case value
|
|
57
91
|
when Integer then value
|
|
@@ -91,7 +125,7 @@ module Textus
|
|
|
91
125
|
textus put KEY --stdin [--fetch=NAME] --as=ROLE
|
|
92
126
|
textus freshness [--prefix=KEY] [--zone=Z]
|
|
93
127
|
textus fetch KEY
|
|
94
|
-
textus fetch
|
|
128
|
+
textus fetch all [--prefix=KEY] [--zone=Z]
|
|
95
129
|
textus audit [--key=K] [--zone=Z] [--role=R] [--verb=V] [--since=X] [--correlation-id=ID] [--limit=N]
|
|
96
130
|
textus blame KEY [--limit=N]
|
|
97
131
|
textus doctor
|
data/lib/textus/container.rb
CHANGED
|
@@ -1,22 +1,10 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
# Single capability record handed to every use case. Replaces the
|
|
3
|
-
# ReadCaps/WriteCaps/HookCaps trio from 0.26.x. Built once per Store
|
|
3
|
+
# ReadCaps/WriteCaps/HookCaps trio from 0.26.x. Built once per Store
|
|
4
|
+
# (see Store#initialize); Store delegates its readers to this record,
|
|
5
|
+
# so this `Data.define` is the single source of truth for the field set.
|
|
4
6
|
Container = Data.define(
|
|
5
7
|
:manifest, :file_store, :schemas, :root,
|
|
6
8
|
:audit_log, :events, :rpc
|
|
7
9
|
)
|
|
8
|
-
|
|
9
|
-
class Container
|
|
10
|
-
def self.from_store(store)
|
|
11
|
-
new(
|
|
12
|
-
manifest: store.manifest,
|
|
13
|
-
file_store: store.file_store,
|
|
14
|
-
schemas: store.schemas,
|
|
15
|
-
root: store.root,
|
|
16
|
-
audit_log: store.audit_log,
|
|
17
|
-
events: store.events,
|
|
18
|
-
rpc: store.rpc,
|
|
19
|
-
)
|
|
20
|
-
end
|
|
21
|
-
end
|
|
22
10
|
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Contract
|
|
3
|
+
# Registry of named, stateful wrappers a verb may declare via `around :name`.
|
|
4
|
+
# A resource implements
|
|
5
|
+
# `wrap(scope:, inputs:, session:) { |effective_inputs| ... }`:
|
|
6
|
+
# it may adjust the inputs before the call and post-process the result after
|
|
7
|
+
# — exactly what build's lock and pulse's cursor need, without a hand-authored
|
|
8
|
+
# CLI class (ADR 0068). `session:` is the dispatching session (nil for the
|
|
9
|
+
# sessionless CLI/Ruby surfaces, present for MCP), so a session-aware resource
|
|
10
|
+
# like the cursor can defer to the session's own state instead of its file.
|
|
11
|
+
module Around
|
|
12
|
+
@registry = {}
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def register(name, resource)
|
|
17
|
+
@registry[name] = resource
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def fetch(name)
|
|
21
|
+
@registry.fetch(name) { raise "no around resource registered: #{name.inspect}" }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def with(name, scope:, inputs:, session: nil, &call)
|
|
25
|
+
fetch(name).wrap(scope: scope, inputs: inputs, session: session, &call)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Contract
|
|
3
|
+
# Raised when a required arg is absent from the bound input. Surface
|
|
4
|
+
# adapters translate this to their native error (MCP ToolError, CLI
|
|
5
|
+
# UsageError); a direct Ruby call lets it surface as-is.
|
|
6
|
+
class MissingArgs < Textus::Error
|
|
7
|
+
attr_reader :spec, :missing
|
|
8
|
+
|
|
9
|
+
def initialize(spec, missing)
|
|
10
|
+
@spec = spec
|
|
11
|
+
@missing = missing
|
|
12
|
+
super("missing_args", "#{spec.verb}: missing #{missing.map(&:wire).join(", ")}")
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# The single argument binder for every surface (spike: collapses the three
|
|
17
|
+
# historical implementations — MCP::Catalog.map_args, CLI::Runner.call_args,
|
|
18
|
+
# and RoleScope's default-injection loop — into one algorithm).
|
|
19
|
+
#
|
|
20
|
+
# Input is a uniform `inputs` hash keyed by arg NAME (the use-case kwarg
|
|
21
|
+
# name, never the wire name): each surface normalizes its own raw transport
|
|
22
|
+
# shape (MCP JSON keyed by wire-name, CLI argv+flags, Ruby args+kwargs) into
|
|
23
|
+
# this hash. Binder owns the shared algorithm and nothing transport-specific:
|
|
24
|
+
#
|
|
25
|
+
# 1. validate every required arg is present in `inputs`;
|
|
26
|
+
# 2. for absentees, fall back to session_default (when a session is given)
|
|
27
|
+
# then to the literal default; otherwise omit the arg entirely;
|
|
28
|
+
# 3. split into the (positional, keyword) pair to splat into the use-case,
|
|
29
|
+
# routing by `arg.positional`.
|
|
30
|
+
#
|
|
31
|
+
# Returns `[positional_array, keyword_hash]` — exactly what
|
|
32
|
+
# `RoleScope#<verb>(*pos, **kw)` expects.
|
|
33
|
+
module Binder
|
|
34
|
+
module_function
|
|
35
|
+
|
|
36
|
+
# Validation is unconditional: a `required:` arg absent from `inputs` is a
|
|
37
|
+
# contract violation on every surface (ADR 0069). `required:` is now an
|
|
38
|
+
# honest contract invariant, not a surface policy — args the use-case
|
|
39
|
+
# treats as optional (e.g. `meta`, whose real requiredness lives in schema
|
|
40
|
+
# validation downstream) are declared `required: false`, so this check
|
|
41
|
+
# never fires spuriously and never needs an opt-out.
|
|
42
|
+
def bind(spec, inputs, session: nil)
|
|
43
|
+
missing = spec.required_args.reject { |a| inputs.key?(a.name) }
|
|
44
|
+
raise MissingArgs.new(spec, missing) unless missing.empty?
|
|
45
|
+
|
|
46
|
+
pos = []
|
|
47
|
+
kw = {}
|
|
48
|
+
spec.args.each do |a|
|
|
49
|
+
if inputs.key?(a.name)
|
|
50
|
+
value = inputs[a.name]
|
|
51
|
+
elsif a.session_default && session
|
|
52
|
+
value = session.public_send(a.session_default)
|
|
53
|
+
elsif !a.default.nil?
|
|
54
|
+
value = a.default
|
|
55
|
+
else
|
|
56
|
+
next
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
if a.positional
|
|
60
|
+
pos << value
|
|
61
|
+
else
|
|
62
|
+
kw[a.name] = value
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
[pos, kw]
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Normalize an ordered positional list + a by-name keyword hash (the shape
|
|
69
|
+
# CLI argv+flags and Ruby args+kwargs both arrive in) into the uniform
|
|
70
|
+
# by-name `inputs` hash bind expects. Positionals beyond what was supplied
|
|
71
|
+
# are dropped so bind's required-check sees them as absent.
|
|
72
|
+
def inputs_from_ordered(spec, ordered_positionals, by_name_keywords)
|
|
73
|
+
names = spec.args.select(&:positional).map(&:name)
|
|
74
|
+
names.zip(ordered_positionals).to_h.compact.merge(by_name_keywords)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Normalize a raw transport hash keyed by WIRE name (the shape MCP JSON
|
|
78
|
+
# arrives in) into the uniform by-name `inputs` hash bind expects. Keys
|
|
79
|
+
# not declared on the contract are ignored.
|
|
80
|
+
def inputs_from_wire(spec, raw)
|
|
81
|
+
raw ||= {}
|
|
82
|
+
spec.args.each_with_object({}) do |a, h|
|
|
83
|
+
h[a.name] = raw[a.wire.to_s] if raw.key?(a.wire.to_s)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|