textus 0.2.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 +7 -0
- data/CHANGELOG.md +163 -0
- data/README.md +200 -0
- data/SPEC.md +720 -0
- data/docs/architecture.md +57 -0
- data/docs/conventions.md +85 -0
- data/exe/textus +4 -0
- data/lib/textus/audit_log.rb +32 -0
- data/lib/textus/builder.rb +191 -0
- data/lib/textus/builtin_fetchers.rb +63 -0
- data/lib/textus/cli.rb +394 -0
- data/lib/textus/dependencies.rb +23 -0
- data/lib/textus/doctor.rb +281 -0
- data/lib/textus/entry/json.rb +41 -0
- data/lib/textus/entry/markdown.rb +39 -0
- data/lib/textus/entry/text.rb +23 -0
- data/lib/textus/entry/yaml.rb +39 -0
- data/lib/textus/entry.rb +30 -0
- data/lib/textus/errors.rb +168 -0
- data/lib/textus/etag.rb +13 -0
- data/lib/textus/extension_registry.rb +48 -0
- data/lib/textus/extensions.rb +29 -0
- data/lib/textus/init.rb +51 -0
- data/lib/textus/intro.rb +104 -0
- data/lib/textus/key_distance.rb +53 -0
- data/lib/textus/manifest.rb +394 -0
- data/lib/textus/migrate_keys.rb +187 -0
- data/lib/textus/mustache.rb +117 -0
- data/lib/textus/projection.rb +80 -0
- data/lib/textus/proposal.rb +27 -0
- data/lib/textus/publisher.rb +71 -0
- data/lib/textus/refresh.rb +75 -0
- data/lib/textus/role.rb +20 -0
- data/lib/textus/schema.rb +90 -0
- data/lib/textus/schema_tools.rb +87 -0
- data/lib/textus/store.rb +607 -0
- data/lib/textus/store_view.rb +18 -0
- data/lib/textus/version.rb +4 -0
- data/lib/textus.rb +31 -0
- metadata +156 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Entry
|
|
5
|
+
# JSON entry storage. Top-level must be an object so we can carry _meta.
|
|
6
|
+
module Json
|
|
7
|
+
def self.parse(raw, path: nil)
|
|
8
|
+
raw = raw.dup.force_encoding(Encoding::UTF_8)
|
|
9
|
+
raise BadFrontmatter.new(path, "entry is not valid UTF-8") unless raw.valid_encoding?
|
|
10
|
+
|
|
11
|
+
begin
|
|
12
|
+
parsed = ::JSON.parse(raw)
|
|
13
|
+
rescue ::JSON::ParserError => e
|
|
14
|
+
raise BadFrontmatter.new(path, "JSON parse failed: #{e.message}")
|
|
15
|
+
end
|
|
16
|
+
raise BadFrontmatter.new(path, "JSON top-level must be an object") unless parsed.is_a?(Hash)
|
|
17
|
+
|
|
18
|
+
meta = parsed["_meta"]
|
|
19
|
+
fm = meta.is_a?(Hash) ? meta : {}
|
|
20
|
+
{ "frontmatter" => fm, "body" => raw, "content" => parsed }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.serialize(frontmatter:, body:, content: nil)
|
|
24
|
+
_ = frontmatter
|
|
25
|
+
if content.is_a?(Hash)
|
|
26
|
+
out = ::JSON.pretty_generate(content)
|
|
27
|
+
out += "\n" unless out.end_with?("\n")
|
|
28
|
+
out
|
|
29
|
+
elsif body && !body.to_s.empty?
|
|
30
|
+
b = body.to_s
|
|
31
|
+
b += "\n" unless b.end_with?("\n")
|
|
32
|
+
b
|
|
33
|
+
else
|
|
34
|
+
raise UsageError.new("json serialize requires :content or :body")
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.extensions = [".json"]
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
require "yaml"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Entry
|
|
5
|
+
# Markdown with YAML frontmatter. Original Entry implementation.
|
|
6
|
+
module Markdown
|
|
7
|
+
def self.parse(raw, path: nil)
|
|
8
|
+
raw = raw.dup.force_encoding(Encoding::UTF_8)
|
|
9
|
+
raise BadFrontmatter.new(path, "entry is not valid UTF-8") unless raw.valid_encoding?
|
|
10
|
+
return { "frontmatter" => {}, "body" => raw, "content" => nil } unless raw.start_with?("---\n") || raw.start_with?("---\r\n")
|
|
11
|
+
|
|
12
|
+
lines = raw.split(/\r?\n/, -1)
|
|
13
|
+
close_idx = lines[1..].index("---")
|
|
14
|
+
raise BadFrontmatter.new(path, "frontmatter not terminated") unless close_idx
|
|
15
|
+
|
|
16
|
+
close_idx += 1
|
|
17
|
+
fm_yaml = lines[1...close_idx].join("\n")
|
|
18
|
+
body = lines[(close_idx + 1)..].join("\n")
|
|
19
|
+
begin
|
|
20
|
+
fm = fm_yaml.strip.empty? ? {} : ::YAML.safe_load(fm_yaml, permitted_classes: [Date, Time], aliases: false)
|
|
21
|
+
rescue Psych::SyntaxError => e
|
|
22
|
+
raise BadFrontmatter.new(path, "YAML parse failed: #{e.message}")
|
|
23
|
+
end
|
|
24
|
+
fm = {} unless fm.is_a?(Hash)
|
|
25
|
+
{ "frontmatter" => fm, "body" => body, "content" => nil }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.serialize(frontmatter:, body:, content: nil)
|
|
29
|
+
_ = content # markdown ignores content
|
|
30
|
+
fm_yaml = frontmatter.empty? ? "" : ::YAML.dump(frontmatter).sub(/\A---\n/, "")
|
|
31
|
+
body = body.to_s
|
|
32
|
+
body += "\n" unless body.empty? || body.end_with?("\n")
|
|
33
|
+
"---\n#{fm_yaml}---\n#{body}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.extensions = [".md"]
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Entry
|
|
3
|
+
# Plain-text entry storage. No frontmatter or structured content.
|
|
4
|
+
module Text
|
|
5
|
+
def self.parse(raw, path: nil)
|
|
6
|
+
raw = raw.dup.force_encoding(Encoding::UTF_8)
|
|
7
|
+
raise BadFrontmatter.new(path, "entry is not valid UTF-8") unless raw.valid_encoding?
|
|
8
|
+
|
|
9
|
+
{ "frontmatter" => {}, "body" => raw, "content" => nil }
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def self.serialize(frontmatter:, body:, content: nil)
|
|
13
|
+
_ = frontmatter
|
|
14
|
+
_ = content
|
|
15
|
+
b = body.to_s
|
|
16
|
+
b += "\n" unless b.empty? || b.end_with?("\n")
|
|
17
|
+
b
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.extensions = [".txt"]
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
require "yaml"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Entry
|
|
5
|
+
# YAML entry storage. Top-level must be a mapping so we can carry _meta.
|
|
6
|
+
module Yaml
|
|
7
|
+
def self.parse(raw, path: nil)
|
|
8
|
+
raw = raw.dup.force_encoding(Encoding::UTF_8)
|
|
9
|
+
raise BadFrontmatter.new(path, "entry is not valid UTF-8") unless raw.valid_encoding?
|
|
10
|
+
|
|
11
|
+
begin
|
|
12
|
+
parsed = ::YAML.safe_load(raw, permitted_classes: [Date, Time], aliases: false)
|
|
13
|
+
rescue Psych::SyntaxError, Psych::AliasesNotEnabled, Psych::DisallowedClass => e
|
|
14
|
+
raise BadFrontmatter.new(path, "YAML parse failed: #{e.message}")
|
|
15
|
+
end
|
|
16
|
+
raise BadFrontmatter.new(path, "YAML top-level must be a mapping") unless parsed.is_a?(Hash)
|
|
17
|
+
|
|
18
|
+
meta = parsed["_meta"]
|
|
19
|
+
fm = meta.is_a?(Hash) ? meta : {}
|
|
20
|
+
{ "frontmatter" => fm, "body" => raw, "content" => parsed }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.serialize(frontmatter:, body:, content: nil)
|
|
24
|
+
_ = frontmatter
|
|
25
|
+
if content.is_a?(Hash)
|
|
26
|
+
::YAML.dump(content).sub(/\A---\n/, "")
|
|
27
|
+
elsif body && !body.to_s.empty?
|
|
28
|
+
b = body.to_s
|
|
29
|
+
b += "\n" unless b.end_with?("\n")
|
|
30
|
+
b
|
|
31
|
+
else
|
|
32
|
+
raise UsageError.new("yaml serialize requires :content or :body")
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.extensions = [".yaml", ".yml"]
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
data/lib/textus/entry.rb
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
require_relative "entry/markdown"
|
|
2
|
+
require_relative "entry/json"
|
|
3
|
+
require_relative "entry/yaml"
|
|
4
|
+
require_relative "entry/text"
|
|
5
|
+
|
|
6
|
+
module Textus
|
|
7
|
+
# Public entry-format dispatcher.
|
|
8
|
+
module Entry
|
|
9
|
+
SEP = "---".freeze
|
|
10
|
+
|
|
11
|
+
STRATEGIES = {
|
|
12
|
+
"markdown" => Markdown,
|
|
13
|
+
"json" => Json,
|
|
14
|
+
"yaml" => Yaml,
|
|
15
|
+
"text" => Text,
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
def self.for_format(format)
|
|
19
|
+
STRATEGIES.fetch(format.to_s) { raise UsageError.new("unknown entry format: #{format.inspect}") }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.parse(raw, path: nil, format: "markdown")
|
|
23
|
+
for_format(format).parse(raw, path: path)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.serialize(frontmatter: {}, body: "", content: nil, format: "markdown")
|
|
27
|
+
for_format(format).serialize(frontmatter: frontmatter, body: body, content: content)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Error < StandardError
|
|
3
|
+
attr_reader :code, :details, :exit_code, :hint
|
|
4
|
+
|
|
5
|
+
def initialize(code, message, details: {}, exit_code: 1, hint: nil)
|
|
6
|
+
super(message)
|
|
7
|
+
@code = code
|
|
8
|
+
@details = details
|
|
9
|
+
@exit_code = exit_code
|
|
10
|
+
@hint = hint
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def to_envelope
|
|
14
|
+
env = {
|
|
15
|
+
"protocol" => Textus::PROTOCOL,
|
|
16
|
+
"ok" => false,
|
|
17
|
+
"code" => @code,
|
|
18
|
+
"message" => message,
|
|
19
|
+
"details" => @details,
|
|
20
|
+
}
|
|
21
|
+
env["hint"] = @hint if @hint
|
|
22
|
+
env
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
class UnknownKey < Error
|
|
27
|
+
attr_reader :suggestions
|
|
28
|
+
|
|
29
|
+
def initialize(key, suggestions: [])
|
|
30
|
+
@suggestions = Array(suggestions)
|
|
31
|
+
details = { "key" => key }
|
|
32
|
+
details["suggestions"] = @suggestions unless @suggestions.empty?
|
|
33
|
+
msg = "key '#{key}' does not resolve"
|
|
34
|
+
msg += "; did you mean: #{@suggestions.join(", ")}" unless @suggestions.empty?
|
|
35
|
+
hint =
|
|
36
|
+
if @suggestions.empty?
|
|
37
|
+
"run 'textus list --format=json' to see all keys"
|
|
38
|
+
else
|
|
39
|
+
"did you mean: #{@suggestions.join(", ")}"
|
|
40
|
+
end
|
|
41
|
+
super("unknown_key", msg, details: details, hint: hint)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
class BadFrontmatter < Error
|
|
46
|
+
def initialize(path, m, hint: nil)
|
|
47
|
+
hint ||= default_hint_for(path, m)
|
|
48
|
+
super("bad_frontmatter", m, details: { "path" => path }, hint: hint)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def default_hint_for(path, m)
|
|
54
|
+
if m.is_a?(String) && (match = m.match(/frontmatter name '([^']+)' does not match basename '([^']+)'/))
|
|
55
|
+
name, basename = match.captures
|
|
56
|
+
ext = File.extname(path)
|
|
57
|
+
"rename the file to '#{name}#{ext}' or change frontmatter name: to '#{basename}'"
|
|
58
|
+
else
|
|
59
|
+
"open #{path} and check the YAML frontmatter for syntax errors"
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
class BadContent < Error
|
|
65
|
+
def initialize(path, m)
|
|
66
|
+
super(
|
|
67
|
+
"bad_content", m,
|
|
68
|
+
details: { "path" => path },
|
|
69
|
+
hint: "JSON/YAML parse failed; run the file through 'jq .' or 'yq .' to find the syntax error",
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
class SchemaViolation < Error
|
|
75
|
+
def initialize(d)
|
|
76
|
+
hint =
|
|
77
|
+
if d.is_a?(Hash) && d["missing"]
|
|
78
|
+
"add the missing field(s) to the entry's frontmatter: #{Array(d["missing"]).join(", ")}"
|
|
79
|
+
elsif d.is_a?(Hash) && d["field"]
|
|
80
|
+
"fix the field '#{d["field"]}' in the entry's frontmatter (#{d["reason"]})"
|
|
81
|
+
end
|
|
82
|
+
super("schema_violation", "schema violation", details: d, hint: hint)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
class WriteForbidden < Error
|
|
87
|
+
def initialize(k, z, writers: nil)
|
|
88
|
+
writers_str =
|
|
89
|
+
if writers && !writers.empty?
|
|
90
|
+
writers.join(", ")
|
|
91
|
+
else
|
|
92
|
+
"the role(s) listed in the manifest 'writable_by:'"
|
|
93
|
+
end
|
|
94
|
+
details = { "key" => k, "zone" => z }
|
|
95
|
+
details["writers"] = writers if writers
|
|
96
|
+
super(
|
|
97
|
+
"write_forbidden",
|
|
98
|
+
"zone '#{z}' is not agent-writable for key '#{k}'",
|
|
99
|
+
details: details,
|
|
100
|
+
hint: "this zone is writable by #{writers_str}; pass --as=<role>",
|
|
101
|
+
)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
class EtagMismatch < Error
|
|
106
|
+
def initialize(k, w, g)
|
|
107
|
+
super(
|
|
108
|
+
"etag_mismatch", "etag mismatch on '#{k}'",
|
|
109
|
+
details: { "key" => k, "wanted" => w, "got" => g },
|
|
110
|
+
hint: "another writer changed this key; run 'textus get #{k}' to fetch the latest etag",
|
|
111
|
+
)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
class IoError < Error
|
|
116
|
+
def initialize(m) = super("io_error", m, exit_code: 64)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
class UsageError < Error
|
|
120
|
+
def initialize(m, hint: nil) = super("usage", m, exit_code: 2, hint: hint)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
class InvalidRole < Error
|
|
124
|
+
def initialize(r)
|
|
125
|
+
super(
|
|
126
|
+
"invalid_role", "role '#{r}' is not declared in any zone",
|
|
127
|
+
details: { "role" => r },
|
|
128
|
+
hint: "valid roles are declared in .textus/manifest.yaml under zones[].writable_by",
|
|
129
|
+
)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
class InvalidProjection < Error
|
|
134
|
+
def initialize(m) = super("invalid_projection", m)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
class TemplateError < Error
|
|
138
|
+
def initialize(m, template_name: nil)
|
|
139
|
+
hint =
|
|
140
|
+
("expected at .textus/templates/#{template_name}; add the file or update the entry's template: field" if template_name)
|
|
141
|
+
super("template_error", m, hint: hint)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
class BadRender < Error
|
|
146
|
+
def initialize(m, format: nil)
|
|
147
|
+
hint =
|
|
148
|
+
if format
|
|
149
|
+
"the template rendered invalid #{format}; try rendering with mock data and parsing the output before re-running build"
|
|
150
|
+
else
|
|
151
|
+
"the template rendered invalid content; try rendering with mock data and parsing the output before re-running build"
|
|
152
|
+
end
|
|
153
|
+
super("bad_render", m, hint: hint)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
class PublishError < Error
|
|
158
|
+
def initialize(m, target: nil)
|
|
159
|
+
hint =
|
|
160
|
+
("file at #{target} wasn't published by textus; back it up and delete it, or move it under .textus/zones/" if target)
|
|
161
|
+
super("publish_error", m, details: target ? { "target" => target } : {}, hint: hint)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
class ProposalError < Error
|
|
166
|
+
def initialize(m) = super("proposal_error", m)
|
|
167
|
+
end
|
|
168
|
+
end
|
data/lib/textus/etag.rb
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class ExtensionRegistry
|
|
3
|
+
EVENTS = %i[put delete refresh build accept].freeze
|
|
4
|
+
|
|
5
|
+
def initialize
|
|
6
|
+
@fetchers = {}
|
|
7
|
+
@reducers = {}
|
|
8
|
+
@hooks = {}
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def register_fetcher(name, &blk)
|
|
12
|
+
name = name.to_sym
|
|
13
|
+
raise UsageError.new("fetcher '#{name}' already registered") if @fetchers.key?(name)
|
|
14
|
+
|
|
15
|
+
@fetchers[name] = blk
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def register_reducer(name, &blk)
|
|
19
|
+
name = name.to_sym
|
|
20
|
+
raise UsageError.new("reducer '#{name}' already registered") if @reducers.key?(name)
|
|
21
|
+
|
|
22
|
+
@reducers[name] = blk
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def register_hook(event, name, &blk)
|
|
26
|
+
event = event.to_sym
|
|
27
|
+
raise UsageError.new("unknown event: #{event}") unless EVENTS.include?(event)
|
|
28
|
+
|
|
29
|
+
(@hooks[event] ||= []) << { name: name.to_sym, callable: blk }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def fetcher(name)
|
|
33
|
+
@fetchers[name.to_sym] or raise UsageError.new("unknown fetcher: #{name}")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def reducer(name)
|
|
37
|
+
@reducers[name.to_sym] or raise UsageError.new("unknown reducer: #{name}")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def hooks(event)
|
|
41
|
+
@hooks[event.to_sym] || []
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def fetcher_names = @fetchers.keys
|
|
45
|
+
def reducer_names = @reducers.keys
|
|
46
|
+
def hook_events = @hooks.keys
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
THREAD_REGISTRY_KEY = :__textus_active_registry__
|
|
3
|
+
private_constant :THREAD_REGISTRY_KEY
|
|
4
|
+
|
|
5
|
+
def self.with_registry(registry)
|
|
6
|
+
prev = Thread.current[THREAD_REGISTRY_KEY]
|
|
7
|
+
Thread.current[THREAD_REGISTRY_KEY] = registry
|
|
8
|
+
yield
|
|
9
|
+
ensure
|
|
10
|
+
Thread.current[THREAD_REGISTRY_KEY] = prev
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.current_registry
|
|
14
|
+
Thread.current[THREAD_REGISTRY_KEY] or
|
|
15
|
+
raise UsageError.new("no active registry; extension code must be loaded by a Store")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.fetcher(name, &)
|
|
19
|
+
current_registry.register_fetcher(name, &)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.reducer(name, &)
|
|
23
|
+
current_registry.register_reducer(name, &)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.hook(event, name, &)
|
|
27
|
+
current_registry.register_hook(event, name, &)
|
|
28
|
+
end
|
|
29
|
+
end
|
data/lib/textus/init.rb
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Init
|
|
5
|
+
ZONES = %w[canon working intake pending derived].freeze
|
|
6
|
+
|
|
7
|
+
DEFAULT_MANIFEST = <<~YAML
|
|
8
|
+
version: textus/1
|
|
9
|
+
zones:
|
|
10
|
+
- { name: canon, writable_by: [human] }
|
|
11
|
+
- { name: working, writable_by: [human, ai, script] }
|
|
12
|
+
- { name: intake, writable_by: [script] }
|
|
13
|
+
- { name: pending, writable_by: [ai, human] }
|
|
14
|
+
- { name: derived, writable_by: [build] }
|
|
15
|
+
entries:
|
|
16
|
+
- { key: canon.identity, path: canon/identity.md, zone: canon, schema: null, owner: human:self }
|
|
17
|
+
- { key: working.notes, path: working/notes, zone: working, schema: null, owner: human:self, nested: true }
|
|
18
|
+
YAML
|
|
19
|
+
|
|
20
|
+
def self.run(target_root)
|
|
21
|
+
raise UsageError.new(".textus/ already exists at #{target_root}") if File.directory?(target_root)
|
|
22
|
+
|
|
23
|
+
FileUtils.mkdir_p(File.join(target_root, "schemas"))
|
|
24
|
+
FileUtils.mkdir_p(File.join(target_root, "templates"))
|
|
25
|
+
FileUtils.mkdir_p(File.join(target_root, "extensions"))
|
|
26
|
+
ZONES.each do |z|
|
|
27
|
+
dir = File.join(target_root, "zones", z)
|
|
28
|
+
FileUtils.mkdir_p(dir)
|
|
29
|
+
File.write(File.join(dir, ".gitkeep"), "")
|
|
30
|
+
end
|
|
31
|
+
File.write(File.join(target_root, "extensions", "README.md"), <<~MD)
|
|
32
|
+
# Extensions
|
|
33
|
+
|
|
34
|
+
Drop one Ruby file per extension. Three verbs are available:
|
|
35
|
+
|
|
36
|
+
```ruby
|
|
37
|
+
Textus.fetcher(:name) { |config:, store:| ... }
|
|
38
|
+
Textus.reducer(:name) { |rows:, config:| ... }
|
|
39
|
+
Textus.hook(:event, :name) { |key:, envelope:, store:, **kw| ... }
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Events: :put, :delete, :refresh, :build, :accept.
|
|
43
|
+
|
|
44
|
+
See SPEC.md §5.11 for the full contract.
|
|
45
|
+
MD
|
|
46
|
+
|
|
47
|
+
File.write(File.join(target_root, "manifest.yaml"), DEFAULT_MANIFEST)
|
|
48
|
+
{ "protocol" => PROTOCOL, "initialized" => target_root }
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
data/lib/textus/intro.rb
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
# Read-only "what's in this store and how do I use it" envelope.
|
|
3
|
+
# A single call gives an agent the working model of a textus-managed
|
|
4
|
+
# project: zones and their write authority, entries and their flags,
|
|
5
|
+
# registered extensions, write flows, and the CLI verb catalog.
|
|
6
|
+
#
|
|
7
|
+
# Intro is side-effect-free.
|
|
8
|
+
module Intro
|
|
9
|
+
PROTOCOL_ID = "textus/1".freeze
|
|
10
|
+
|
|
11
|
+
# Conventional zone purposes. Unknown zones (declared in the manifest
|
|
12
|
+
# but not listed here) get no `purpose` field.
|
|
13
|
+
ZONE_PURPOSES = {
|
|
14
|
+
"canon" => "slow-changing identity; human-only writes",
|
|
15
|
+
"working" => "active project state; humans, AI, and scripts share this surface",
|
|
16
|
+
"intake" => "declared external inputs; script-refreshed via fetchers",
|
|
17
|
+
"pending" => "AI proposals awaiting human accept",
|
|
18
|
+
"derived" => "build-computed outputs; never hand-edited",
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
WRITE_FLOWS = {
|
|
22
|
+
"human" => "edit files in canon/working zones, then 'textus put KEY --as=human'",
|
|
23
|
+
"ai" => "propose changes by writing 'pending.*' entries with --as=ai and a 'proposal:' frontmatter block; " \
|
|
24
|
+
"a human runs 'textus accept' to apply",
|
|
25
|
+
"script" => "refresh intake entries with 'textus refresh KEY --as=script' (uses the entry's declared fetcher)",
|
|
26
|
+
"build" => "'textus build' computes derived entries from projections; derived files are never hand-edited",
|
|
27
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
# The CLI verb catalog. Truth lives here; do not derive dynamically.
|
|
30
|
+
# Agents that read intro should see a stable shape regardless of how
|
|
31
|
+
# verb implementations evolve.
|
|
32
|
+
CLI_VERBS = [
|
|
33
|
+
{ "name" => "intro", "summary" => "this output — orientation for agents and tools" },
|
|
34
|
+
{ "name" => "list", "summary" => "enumerate keys (optional --prefix)" },
|
|
35
|
+
{ "name" => "get", "summary" => "read an entry; envelope with frontmatter, body, uid, etag" },
|
|
36
|
+
{ "name" => "where", "summary" => "resolve a key to its zone and path without reading" },
|
|
37
|
+
{ "name" => "schema", "summary" => "field shape for a key family" },
|
|
38
|
+
{ "name" => "put", "summary" => "write an entry; --as=<role>, --stdin payload" },
|
|
39
|
+
{ "name" => "accept", "summary" => "apply a pending.* proposal; --as=human only" },
|
|
40
|
+
{ "name" => "mv", "summary" => "rename a key in place; uid preserved, audit row written" },
|
|
41
|
+
{ "name" => "delete", "summary" => "delete an entry; --as=<role>" },
|
|
42
|
+
{ "name" => "build", "summary" => "materialize derived entries; publish_to and publish_each fan out copies" },
|
|
43
|
+
{ "name" => "refresh", "summary" => "run a fetcher for an intake entry" },
|
|
44
|
+
{ "name" => "stale", "summary" => "list derived/intake entries past their freshness check" },
|
|
45
|
+
{ "name" => "doctor", "summary" => "health-check the store (missing schemas, illegal keys, sentinel drift, etc.)" },
|
|
46
|
+
{ "name" => "migrate-keys", "summary" => "rename files whose basenames violate the strict key grammar" },
|
|
47
|
+
{ "name" => "extensions", "summary" => "list registered reducers/fetchers/hooks" },
|
|
48
|
+
].freeze
|
|
49
|
+
|
|
50
|
+
def self.run(store)
|
|
51
|
+
{
|
|
52
|
+
"protocol" => PROTOCOL_ID,
|
|
53
|
+
"store_root" => store.root,
|
|
54
|
+
"zones" => zones_for(store),
|
|
55
|
+
"entries" => entries_for(store),
|
|
56
|
+
"extensions" => extensions_for(store),
|
|
57
|
+
"write_flows" => WRITE_FLOWS.dup,
|
|
58
|
+
"cli_verbs" => CLI_VERBS.map(&:dup),
|
|
59
|
+
"docs" => { "spec" => "SPEC.md", "example" => "examples/claude-plugin/" },
|
|
60
|
+
}
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def self.zones_for(store)
|
|
64
|
+
store.manifest.zones.map do |name, writers|
|
|
65
|
+
row = { "name" => name, "writers" => Array(writers) }
|
|
66
|
+
purpose = ZONE_PURPOSES[name]
|
|
67
|
+
row["purpose"] = purpose if purpose
|
|
68
|
+
row
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def self.entries_for(store)
|
|
73
|
+
store.manifest.entries.map do |e|
|
|
74
|
+
derived = store.manifest.zone_writers(e.zone).include?("build")
|
|
75
|
+
{
|
|
76
|
+
"key" => e.key,
|
|
77
|
+
"zone" => e.zone,
|
|
78
|
+
"schema" => e.schema,
|
|
79
|
+
"nested" => e.nested ? true : false,
|
|
80
|
+
"owner" => e.owner,
|
|
81
|
+
"format" => e.format,
|
|
82
|
+
"derived" => derived,
|
|
83
|
+
"intake" => !e.fetcher.nil?,
|
|
84
|
+
"publish_to" => Array(e.publish_to),
|
|
85
|
+
"publish_each" => e.publish_each,
|
|
86
|
+
}
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def self.extensions_for(store)
|
|
91
|
+
reg = store.registry
|
|
92
|
+
reducers = reg.reducer_names.map(&:to_s).sort
|
|
93
|
+
fetchers = reg.fetcher_names.map(&:to_s).sort
|
|
94
|
+
hooks = reg.hook_events.flat_map do |evt|
|
|
95
|
+
reg.hooks(evt).map { |h| { "event" => evt.to_s, "name" => h[:name].to_s } }
|
|
96
|
+
end.sort_by { |h| [h["event"], h["name"]] }
|
|
97
|
+
{
|
|
98
|
+
"reducers" => reducers,
|
|
99
|
+
"fetchers" => fetchers,
|
|
100
|
+
"hooks" => hooks,
|
|
101
|
+
}
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
# Small utilities for ranking key suggestions. Bounded inputs only —
|
|
3
|
+
# Levenshtein is O(n*m) so we refuse to compute on long strings.
|
|
4
|
+
module KeyDistance
|
|
5
|
+
MAX_LEN = 200
|
|
6
|
+
|
|
7
|
+
# Length of the shared dot-separated prefix between two dotted keys.
|
|
8
|
+
def self.shared_prefix_segments(left, right)
|
|
9
|
+
asegs = left.split(".")
|
|
10
|
+
bsegs = right.split(".")
|
|
11
|
+
n = [asegs.length, bsegs.length].min
|
|
12
|
+
i = 0
|
|
13
|
+
i += 1 while i < n && asegs[i] == bsegs[i]
|
|
14
|
+
i
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Classic iterative Levenshtein with two rows. Bounded to MAX_LEN.
|
|
18
|
+
def self.levenshtein(left, right)
|
|
19
|
+
return nil if left.length > MAX_LEN || right.length > MAX_LEN
|
|
20
|
+
return right.length if left.empty?
|
|
21
|
+
return left.length if right.empty?
|
|
22
|
+
|
|
23
|
+
prev = (0..right.length).to_a
|
|
24
|
+
curr = Array.new(right.length + 1, 0)
|
|
25
|
+
(1..left.length).each do |i|
|
|
26
|
+
curr[0] = i
|
|
27
|
+
(1..right.length).each do |j|
|
|
28
|
+
cost = left[i - 1] == right[j - 1] ? 0 : 1
|
|
29
|
+
curr[j] = [
|
|
30
|
+
curr[j - 1] + 1, # insertion
|
|
31
|
+
prev[j] + 1, # deletion
|
|
32
|
+
prev[j - 1] + cost, # substitution
|
|
33
|
+
].min
|
|
34
|
+
end
|
|
35
|
+
prev, curr = curr, prev
|
|
36
|
+
end
|
|
37
|
+
prev[right.length]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Rank candidate keys against requested. Returns up to `limit` keys.
|
|
41
|
+
# Sort: longer shared prefix first; then smaller Levenshtein distance.
|
|
42
|
+
def self.suggest(requested, candidates, limit: 5)
|
|
43
|
+
return [] if requested.nil? || requested.empty?
|
|
44
|
+
|
|
45
|
+
scored = candidates.first(200).map do |k|
|
|
46
|
+
prefix = shared_prefix_segments(requested, k)
|
|
47
|
+
dist = levenshtein(requested, k) || Float::INFINITY
|
|
48
|
+
[k, prefix, dist]
|
|
49
|
+
end
|
|
50
|
+
scored.sort_by { |(_, prefix, dist)| [-prefix, dist] }.first(limit).map(&:first)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|