holivia 0.2.0 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7e08b2bf86b483bed3cab49da55f6a200ce9127837190f0e996001882704a47f
4
- data.tar.gz: 3e43af44fdafedba928d56ba824544d884524ccbeb97eb7f078d63c816c49440
3
+ metadata.gz: 6e2d692d4098918b86e1fb733a303c51b8bde37583841b2f97f11bf58b2da880
4
+ data.tar.gz: 14095f6175eb6fdae474eeb6b31c0b4c856a675da375461d8d5a0748c27ec022
5
5
  SHA512:
6
- metadata.gz: ab7c5ee66ff7f46620407a9aab99a2c3182a19e76436e9a65c8ac78aa264205ff1095fe8ff5e81e067921e4c7108aed142417642e89a13a512da45a76ceb658b
7
- data.tar.gz: 7fc1562d3ff1228d3c0775576bf2204be61d90fbb45054ecb9eec2574c45bc1c83d37ea5a37bc2ea5ec088a3a7453978c548c90428ded7736b975f55cf367646
6
+ metadata.gz: a881a29e44e585bee272a5fe5def48a57b089ca81be00c9ee9d54da355ecd8273c4befbedd98e26d77a978baa0a37b5b12d2a2f1af03afac10519e85e291053f
7
+ data.tar.gz: 1478b0f331188b5b5043f01ab4e80643b173f69bb04adeefeba87d37a28ceda194d66fb7c701034298ad2257d6afae38131990b760829c0ba332ab586425ff1a
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.3.0] - 2026-03-11
4
+
5
+ - Add multi-environment support with `~/.holivia/config.yml`
6
+ - Add `holivia env` commands (show, list, add, use, remove, debug)
7
+ - Add `holivia version` command
8
+ - Add scriptable login with `--email`/`--password` flags
9
+ - Add `ConfigError` for friendly CLI error messages
10
+ - Add `update_env` for toggling debug per environment
11
+ - Add selfcare CRUD commands (show, create, update, compose, schema)
12
+ - Add content format, slide, and slide item commands
13
+ - Allow removing current env with auto-switch to remaining
14
+ - Update compose example to omit audio items (uploaded separately)
15
+ - Update help with workflow for audio uploads and all new commands
16
+
3
17
  ## [0.2.0] - 2026-03-09
4
18
 
5
19
  - Add logout command and `Client#delete` method
@@ -0,0 +1,52 @@
1
+ <slides>
2
+ <slide title="Les relations, un déterminant vital de la santé" duration="1">
3
+ <slideItem type="richText">
4
+ Imaginez une consultation médicale tout à fait banale. On vous interroge sur le tabac, l'activité physique, la tension artérielle, l'alimentation… Et puis, soudain, une question inattendue : « Et vos relations, en ce moment, elles ressemblent à quoi ? » Sur le papier, cela paraît presque déplacé. Dans la vie réelle, c'est souvent l'inverse : nous traitons la qualité de nos liens comme un "bonus" — précieux, certes, mais secondaire. Or, une vaste synthèse scientifique invite à changer radicalement de perspective : les relations sociales ne relèvent pas seulement du confort psychologique. Elles pèsent sur la survie elle-même.
5
+ </slideItem>
6
+ </slide>
7
+ <slide title="Quand le lien social devient un "facteur de survie"" duration="1">
8
+ <slideItem type="richText">
9
+ Dans une vaste synthèse d'études scientifiques qui suivent des personnes dans le temps, Holt-Lunstad et ses collègues montrent que les individus disposant de relations sociales plus solides ont tendance à vivre plus longtemps que ceux dont les liens sont plus faibles. Autrement dit, à l'échelle d'une vie, la présence de liens sociaux robustes s'associe à un avantage comparable à celui de facteurs de santé classiquement scrutés.<br/><br/>Les auteurs vont plus loin : ils soulignent que l'importance des relations "rivalise" avec des risques bien connus (comme certains facteurs de mode de vie et biomédicaux) — au point de recommander que la question des liens sociaux soit prise au sérieux dans une perspective de santé publique.<br/><br/>Une précision essentielle : cet effet n'est pas cantonné à un sous-groupe fragile. Il apparaît robuste à travers l'âge, le sexe, le statut de santé initial (population générale vs patients), et différents types de mortalité. Ce que cela suggère, en filigrane : le lien social n'est pas seulement une ressource "quand tout va mal", mais une dimension continue de notre écologie de santé.
10
+ </slideItem>
11
+ </slide>
12
+ <slide title=""Avoir des relations", cela veut dire quoi, exactement ?" duration="1">
13
+ <slideItem type="richText">
14
+ L'un des apports les plus utiles de l'article, pour penser concrètement nos vies, est de montrer que "les relations" ne se résument pas à une variable unique. Les auteurs distinguent deux grandes dimensions :<br/><br/>1) La dimension structurelle : à quel point vous êtes "inséré" dans un tissu social<br/><ul><li>vivre seul ou non,</li><li>être marié ou non,</li><li>taille et densité du réseau,</li><li>participation à des activités, rôles sociaux (ami, collègue, voisin, membre d'un groupe),</li><li>indices composites d'intégration sociale (qui combinent plusieurs éléments).</li></ul><br/>2) La dimension fonctionnelle : ce que ces relations "font" pour vous<br/><ul><li>le soutien reçu (aide concrète, émotionnelle, informationnelle),</li><li>le soutien perçu (la conviction que l'aide serait disponible si nécessaire),</li><li>la solitude (se sentir isolé, ne pas appartenir).</li></ul><br/>Et voici une nuance psychologiquement très parlante : ces dimensions ne se recouvrent que partiellement. On peut être "bien entouré" en apparence et se sentir seul ; ou avoir peu de liens mais les vivre comme profondément fiables et soutenants.
15
+ </slideItem>
16
+ </slide>
17
+ <slide title="Ce qui protège le plus : l'intégration sociale… et l'expérience subjective" duration="1">
18
+ <slideItem type="richText">
19
+ Quand on regarde plus finement quelles mesures prédisent le mieux la survie, un résultat se détache : la profondeur et la diversité de l'intégration sociale semblent particulièrement puissantes.<br/><br/>Mais l'autre message fort est que l'expérience subjective compte énormément :<br/><ul><li>le soutien perçu est protecteur</li><li>une moindre solitude apparaît fortement associée à la survie</li><li>tandis que des indicateurs très simples (ex. "vivre seul ou non") sont moins prédictifs</li></ul><br/>Un point particulièrement mobilisateur : les auteurs nuancent l'idée d'un simple "seuil" (les très isolés iraient mal, les autres seraient "OK"). Ils décrivent plutôt un gradient : chaque pas vers davantage d'intégration, de soutien perçu, et moins de solitude s'associe, en moyenne, à un gain incrémental. Dit autrement : en matière de liens, <b>les petits ajustements peuvent compter</b>.
20
+ </slideItem>
21
+ </slide>
22
+ <slide title="Comment les relations influencent-elles la santé "par en dessous" ?" duration="1">
23
+ <slideItem type="richText">
24
+ L'article discute plusieurs voies explicatives — et c'est ici que le lien social cesse d'être abstrait.<br/><br/>1) Les voies comportementales<br/>Les relations façonnent nos habitudes : elles peuvent encourager ou modéliser des comportements de santé (activité physique, alimentation, suivi médical, adhérence aux traitements), via des normes implicites et une régulation quotidienne ("on se motive", "on veille", "on se rappelle"). Les auteurs notent aussi que l'environnement social peut renforcer des habitudes moins favorables, même si ce n'est pas le cœur de la vaste synthèse d'études scientifiques.<br/><br/>2) Les voies psychologiques<br/>Deux grands modèles sont mis en avant :<br/><ul><li>le effet tampon face au stress : en période d'épreuve (maladie, transition, deuil, crise), le soutien relationnel modifie l'évaluation cognitive de la situation et amortit les réponses émotionnelles ;</li><li>le modèle des effets généraux : même hors crise, être relié à des autres structure l'identité, apporte des rôles, du sens, de l'estime de soi, et organise la vie quotidienne de manière plus contenante.</li></ul><br/>3) Les voies biologiques<br/>Les auteurs évoquent des liens entre soutien social et paramètres physiologiques (fonction immunitaire, inflammation, régulation neuroendocrinienne du stress). Ils rappellent aussi que des interactions relationnelles négatives (hostilité conjugale, climat tendu) ont été associées à des marqueurs défavorables (inflammation, cicatrisation plus lente), illustrant la façon dont la qualité relationnelle peut "passer dans le corps".
25
+ </slideItem>
26
+ </slide>
27
+ <slide title="L'angle mort qui change tout : la qualité des liens" duration="1">
28
+ <slideItem type="richText">
29
+ La vaste synthèse d'études scientifiques montre un effet global : "plus de relations" s'associe à une meilleure survie. Mais les auteurs insistent sur une limite majeure : beaucoup de mesures ne captent pas la qualité (par exemple, "être marié" ne dit rien d'un mariage soutenant ou corrosif). Ils rappellent que les relations négatives sont liées à davantage de risque, et suggèrent que les bénéfices observés pourraient même être sous-estimés si l'on distinguait mieux les liens protecteurs des liens toxiques.<br/><br/>Message psychoéducatif clé : augmenter le contact ne suffit pas. L'enjeu est d'augmenter la <b>fiabilité</b>, la <b>chaleur</b>, la <b>réciprocité</b> — et, parfois, de réduire l'exposition à des interactions chroniquement délétères.
30
+ </slideItem>
31
+ </slide>
32
+ <slide title="Votre "bilan relationnel santé"" duration="1">
33
+ <slideItem type="richText">
34
+ Prenez une feuille. Dessinez trois colonnes : <b>Structure</b> / <b>Fonction</b> / <b>Climat</b>.<br/><br/>1) Structure : votre intégration (0–2 points par item)<br/><ul><li>Ai-je au moins deux rôles sociaux vivants en dehors du travail (ami, voisin, groupe, association, activité) ?</li><li>Est-ce que je participe à au moins une activité régulière (même modeste) qui me met en contact ?</li><li>Ai-je des liens dans plus d'un contexte (famille et amis, ou amis et collectif, etc.) ?</li></ul><br/>2) Fonction : le soutien perçu et la solitude (0–2 points)<br/><ul><li>Si j'avais un pépin, est-ce que je sais qui je pourrais appeler ?</li><li>Est-ce que je me sens "appartenir" à au moins un petit cercle où je compte ?</li><li>Est-ce que je vis souvent une <b>solitude douloureuse</b>, même entouré (inversez le score : 2 = rarement, 0 = souvent) ?</li></ul><br/>3) Climat : la qualité (0–2 points)<br/><ul><li>Mes relations clés sont-elles plutôt une source de sécurité que de tension ?</li><li>Est-ce que je peux être moi-même sans marcher sur des œufs ?</li><li>Ai-je une relation où la réciprocité est réelle (je donne et je reçois) ?</li></ul><br/>Additionnez. Puis notez, en une phrase : "Mon prochain pas relationnel santé, réaliste, serait…"<br/><br/>L'objectif n'est pas de vous juger, mais de repérer où un petit déplacement aurait le plus d'effet.
35
+ </slideItem>
36
+ </slide>
37
+ <slide title="Mise en pratique : 5 stratégies simples" duration="1">
38
+ <slideItem type="richText">
39
+ <ol><li><b>Miser sur l'intégration "en profondeur", pas sur l'accumulation</b><br/>Les indices complexes d'intégration sociale sont ceux qui ressortent le plus fortement. Concrètement : plutôt qu'ajouter des contacts, cherchez un rôle stable (activité, groupe, engagement léger) qui vous donne une place reconnaissable.</li><li><b>Travailler le soutien perçu : rendre l'aide imaginable</b><br/>Le soutien perçu est un prédicteur notable. Une action très simple : choisissez une personne et testez une demande petite et précise ("Tu aurais 10 minutes cette semaine pour que je te parle d'un truc ?"). Ce type de micro-expérience rend le soutien plus "réel" mentalement.</li><li><b>Si vous êtes seul : privilégier des formats qui nourrissent vraiment le lien</b><br/>L'isolement n'est pas qu'une affaire de logement : c'est aussi une affaire de qualité de connexion et de sentiment d'appartenance. Or la vaste synthèse d'études scientifiques souligne l'importance de la solitude et de l'expérience subjective. Cherchez un format où l'on se voit suffisamment pour qu'un lien s'épaississe (rendez-vous régulier, activité partagée, marche hebdo, club).</li><li><b>Faire de l'hygiène relationnelle : réduire ce qui "abîme" le corps</b><br/>Les auteurs rappellent que les relations négatives ne sont pas neutres. Sans dramatiser : identifiez une interaction récurrente qui vous laisse vidé, tendu, humilié — et introduisez une limite concrète (durée, sujet, fréquence), ou un tiers, ou une distance. La santé relationnelle n'est pas seulement l'ajout de liens : c'est aussi la diminution de l'érosion.</li><li><b>Relier santé et relations : impliquer votre réseau, même modestement</b><br/>Les auteurs plaident pour que les soins et la prévention intègrent le réseau social (par exemple, soutien dans l'adhérence, présence à des étapes clés, organisation). À l'échelle individuelle : demandez à quelqu'un de vous accompagner dans un objectif de santé (marche, rendez-vous, routine), non comme "coach", mais comme <b>allié</b>.</li></ol>
40
+ </slideItem>
41
+ </slide>
42
+ <slide title="Conclusion : traiter le lien social comme une infrastructure de santé" duration="1">
43
+ <slideItem type="richText">
44
+ Une des idées les plus fortes de Holt-Lunstad et al. est presque philosophique : <b>nous n'existons pas en isolation</b>. Les relations influencent la santé par des voies cognitives, émotionnelles, comportementales et biologiques — lentement, cumulativement, parfois silencieusement.<br/><br/>La bonne nouvelle, si l'on adopte la logique du gradient : vous n'avez pas besoin de "devenir ultra-sociable" du jour au lendemain pour que quelque chose bouge. Un rôle social de plus, une relation nourrie avec plus de fiabilité, un pas qui réduit la solitude, une limite posée face à une interaction délétère… peuvent constituer des investissements de santé au même titre que certaines routines de prévention.<br/><br/>Si vous sentez que la question relationnelle est sensible (solitude tenace, histoire de liens douloureux, difficulté à demander, tendance à s'isoler), un accompagnement peut aider à faire ces pas de manière progressive et sécurisée — par exemple avec un expert Holivia, pour transformer des intentions vagues ("il faut que je voie plus de monde") en stratégies fines et soutenables.
45
+ </slideItem>
46
+ </slide>
47
+ <slide title="Référence" duration="1">
48
+ <slideItem type="richText">
49
+ Holt-Lunstad, J., Smith, T. B., &amp; Layton, J. B. (2010). Social relationships and mortality risk: A meta-analytic review. <i>PLOS Medicine</i>, 7(7), e1000316. https://doi.org/10.1371/journal.pmed.1000316
50
+ </slideItem>
51
+ </slide>
52
+ </slides>
@@ -0,0 +1,58 @@
1
+ {
2
+ "title": "Gestion du stress au travail",
3
+ "duration": 20,
4
+ "description": "Parcours complet sur le stress professionnel",
5
+ "goal": "Comprendre et réduire le stress",
6
+ "content_type": "video_type",
7
+ "locale": "fr",
8
+ "formats": [
9
+ {
10
+ "format_type": "video",
11
+ "slides": [
12
+ {
13
+ "title": "Vidéo principale",
14
+ "duration": 8,
15
+ "items": [
16
+ { "item_type": "WistiaItem", "code": "abc123def" }
17
+ ]
18
+ }
19
+ ]
20
+ },
21
+ {
22
+ "format_type": "audio",
23
+ "slides": [
24
+ {
25
+ "title": "Version audio",
26
+ "duration": 5
27
+ }
28
+ ]
29
+ },
30
+ {
31
+ "format_type": "text",
32
+ "slides": [
33
+ {
34
+ "title": "Introduction",
35
+ "duration": 3,
36
+ "items": [
37
+ { "item_type": "RichTextItem", "content": "<h2>Le stress</h2><p>Le stress est une réaction naturelle de l'organisme...</p>" }
38
+ ]
39
+ },
40
+ {
41
+ "title": "Les causes principales",
42
+ "duration": 4,
43
+ "items": [
44
+ { "item_type": "RichTextItem", "content": "<h2>Causes</h2><ul><li>Surcharge de travail</li><li>Manque de contrôle</li><li>Conflits interpersonnels</li></ul>" },
45
+ { "item_type": "RichTextItem", "content": "<h3>Facteurs aggravants</h3><p>Le manque de sommeil et la sédentarité amplifient les effets du stress.</p>" }
46
+ ]
47
+ },
48
+ {
49
+ "title": "Techniques de gestion",
50
+ "duration": 5,
51
+ "items": [
52
+ { "item_type": "RichTextItem", "content": "<h2>3 techniques simples</h2><ol><li>La respiration abdominale</li><li>La pause active</li><li>Le recadrage cognitif</li></ol>" }
53
+ ]
54
+ }
55
+ ]
56
+ }
57
+ ]
58
+ }
data/exe/holivia CHANGED
@@ -6,6 +6,10 @@ require "holivia/cli"
6
6
  begin
7
7
  Holivia::CLI.start(ARGV)
8
8
  rescue Holivia::ApiError => e
9
+ warn "Error (#{e.status}): #{e.message}"
10
+ warn JSON.pretty_generate(e.details) if e.details.is_a?(Hash)
11
+ exit 1
12
+ rescue Holivia::ConfigError => e
9
13
  warn "Error: #{e.message}"
10
14
  exit 1
11
15
  end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Holivia
4
+ class CLI
5
+ module Help
6
+ HELP_TEXT = <<~HELP
7
+ HOLIVIA CLI
8
+ ===========
9
+
10
+ Environment:
11
+ holivia env Show current environment
12
+ holivia env list List all configured environments
13
+ holivia env add <name> --url <url> Add or update an environment (--debug / --no-debug)
14
+ holivia env use <name> Switch to an environment
15
+ holivia env remove <name> Remove an environment and its credentials
16
+ holivia env debug [name] --true/--false Toggle debug logging for an environment
17
+ Config stored at ~/.holivia/config.yml. Defaults to staging if no config exists.
18
+
19
+ Authentication:
20
+ holivia login [--email EMAIL --password PASSWORD] Log in (flags or interactive prompt)
21
+ holivia logout Log out and clear stored credentials
22
+ Tokens are stored at ~/.holivia/credentials.<env>.json and refreshed automatically on 401.
23
+
24
+ Selfcare Contents:
25
+ holivia selfcare index List all selfcare contents
26
+ holivia selfcare show <id> Show a selfcare content by id
27
+ holivia selfcare create [options] Create a selfcare content
28
+ holivia selfcare update <id> [options] Update a selfcare content
29
+ holivia selfcare compose Create a full content tree atomically (no audio)
30
+ holivia selfcare schema Show allowed values for content_types, format_types, item_types
31
+
32
+ Content Formats:
33
+ holivia selfcare format create [options] Add a format to a selfcare content
34
+ holivia selfcare format update <id> [options] Update a content format
35
+
36
+ Slides:
37
+ holivia selfcare slide create [options] Add a slide to a format
38
+ holivia selfcare slide update <id> [options] Update a slide
39
+
40
+ Slide Items:
41
+ holivia selfcare item create [options] Add an item to a slide
42
+ holivia selfcare item update <id> [options] Update a slide item
43
+
44
+ Data Model:
45
+ SelfcareContent → ContentFormat (format_type: text, audio, video)
46
+ → Slide (title, duration) → SlideItem (one of: RichTextItem, VideoItem,
47
+ WistiaItem, QuizItem, AudioItem, MeditationItem, BreathingItem)
48
+ BreathingItem and MeditationItem are rendered full screen: one per slide.
49
+
50
+ Workflow:
51
+ 1. holivia selfcare schema Discover valid enum values
52
+ 2. holivia selfcare compose Create a full content tree in one atomic request
53
+ Text and video items are created inline. Audio items require a file upload,
54
+ so compose creates the slide without items — then add them using the slide ID:
55
+ holivia selfcare item create --slide-id <id> --item-type AudioItem --audio <path>
56
+ OR build everything incrementally:
57
+ holivia selfcare create → create the content
58
+ holivia selfcare format create → attach a format
59
+ holivia selfcare slide create → add a slide to the format
60
+ holivia selfcare item create → add items to the slide (supports audio upload)
61
+
62
+ Data Input:
63
+ Create/update commands accept flags (run <command> --help for details).
64
+ Compose accepts --file <path> for a JSON payload.
65
+ All create/update/compose commands also accept piped JSON via stdin.
66
+ Audio uploads use --audio <path> on item create/update (sent as multipart/form-data).
67
+ Accepted audio formats: MP3, MP4, WAV, OGG, FLAC, AAC, M4A, WebM. Max size: 100 MB.
68
+
69
+ Errors:
70
+ Validation errors (422) show structured details:
71
+ path: "formats[0].slides[0]" errors: { "title": ["can't be blank"] }
72
+ Auth errors return 401. Not found returns 404.
73
+
74
+ Discovery:
75
+ Run holivia selfcare schema to get current allowed values for all enums
76
+ and item types with their permitted params. Always use this over hardcoded
77
+ values — it is the source of truth.
78
+
79
+ Version:
80
+ holivia version Print version
81
+ HELP
82
+ end
83
+ end
84
+ end
data/lib/holivia/cli.rb CHANGED
@@ -1,39 +1,28 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "holivia"
4
+ require_relative "cli/help"
4
5
  require_relative "commands/auth"
5
6
  require_relative "commands/selfcare"
7
+ require_relative "commands/format"
8
+ require_relative "commands/slide"
9
+ require_relative "commands/env"
10
+ require_relative "commands/item"
6
11
 
7
12
  module Holivia
8
13
  class CLI
9
14
  def self.start(args)
10
15
  command = args.shift
11
16
  case command
12
- when "login" then Commands::Auth.new.login
17
+ when "login" then Commands::Auth.new.login(args)
13
18
  when "logout" then Commands::Auth.new.logout
14
- when "selfcare" then route_selfcare(args)
15
- when "--help", "-h", nil then print_help
19
+ when "env" then Commands::Env.route(args)
20
+ when "selfcare" then Commands::Selfcare.route(args)
21
+ when "version", "--version", "-v" then puts "holivia #{Holivia::VERSION}"
22
+ when "--help", "-h", nil then puts Help::HELP_TEXT
16
23
  else warn "Unknown command: #{command}"
17
24
  exit 1
18
25
  end
19
26
  end
20
-
21
- def self.route_selfcare(args)
22
- subcommand = args.shift
23
- case subcommand
24
- when "index" then Commands::Selfcare.new.index
25
- else warn "Unknown selfcare command: #{subcommand}"
26
- exit 1
27
- end
28
- end
29
-
30
- def self.print_help
31
- puts "Usage: holivia <command>"
32
- puts ""
33
- puts "Commands:"
34
- puts " login Log in to Holivia"
35
- puts " logout Log out of Holivia"
36
- puts " selfcare index List selfcare contents"
37
- end
38
27
  end
39
28
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "faraday"
4
+ require "faraday/multipart"
4
5
 
5
6
  module Holivia
6
7
  class Client
@@ -18,6 +19,10 @@ module Holivia
18
19
  request(:get, path, params:, headers:)
19
20
  end
20
21
 
22
+ def patch(path, body: {}, headers: {})
23
+ request(:patch, path, body:, headers:)
24
+ end
25
+
21
26
  def delete(path, headers: {})
22
27
  request(:delete, path, headers:)
23
28
  end
@@ -52,8 +57,9 @@ module Holivia
52
57
 
53
58
  def connection
54
59
  @connection ||= http.new(url: base_url) do |f|
60
+ f.request :multipart
55
61
  f.request :json
56
- f.response :logger if Holivia.configuration.debug
62
+ f.response :logger if Holivia.configuration.debug?
57
63
  f.response :json
58
64
  f.adapter Faraday.default_adapter
59
65
  end
@@ -1,16 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "io/console"
4
+ require "optparse"
4
5
 
5
6
  module Holivia
6
7
  module Commands
7
8
  class Auth
8
- def login
9
- print "Email: "
10
- email = $stdin.gets.chomp
11
- print "Password: "
12
- password = $stdin.noecho(&:gets).chomp
13
- puts
9
+ def login(args = [])
10
+ options = {}
11
+ OptionParser.new do |opts|
12
+ opts.banner = "Usage: holivia login [--email EMAIL --password PASSWORD]"
13
+ opts.on("--email EMAIL") { |v| options[:email] = v }
14
+ opts.on("--password PASSWORD") { |v| options[:password] = v }
15
+ end.parse!(args)
16
+
17
+ email = options[:email] || prompt("Email: ")
18
+ password = options[:password] || prompt_secret("Password: ")
14
19
 
15
20
  Holivia::Auth.new.login(email: email, password: password)
16
21
  puts "Logged in successfully."
@@ -20,6 +25,20 @@ module Holivia
20
25
  Holivia::Auth.new.logout
21
26
  puts "Logged out successfully."
22
27
  end
28
+
29
+ private
30
+
31
+ def prompt(label)
32
+ print label
33
+ $stdin.gets.chomp
34
+ end
35
+
36
+ def prompt_secret(label)
37
+ print label
38
+ result = $stdin.noecho(&:gets).chomp
39
+ puts
40
+ result
41
+ end
23
42
  end
24
43
  end
25
44
  end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module Holivia
6
+ module Commands
7
+ class Base
8
+ private
9
+
10
+ def client
11
+ @client ||= Client.new
12
+ end
13
+
14
+ def output(response)
15
+ puts JSON.pretty_generate(response)
16
+ end
17
+
18
+ def piped_json
19
+ return {} if $stdin.tty?
20
+
21
+ input = $stdin.read
22
+ return {} if input.empty?
23
+
24
+ JSON.parse(input, symbolize_names: true)
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Holivia
6
+ module Commands
7
+ class Env < Base
8
+ def self.route(args)
9
+ subcommand = args.shift
10
+ case subcommand
11
+ when "add" then new.add(args)
12
+ when "use" then new.use(args)
13
+ when "remove" then new.remove(args)
14
+ when "debug" then new.debug(args)
15
+ when "list" then new.list
16
+ when nil then new.show
17
+ else warn "Unknown env command: #{subcommand}"
18
+ exit 1
19
+ end
20
+ end
21
+
22
+ def show
23
+ puts "Environment: #{config.current_env}"
24
+ puts "URL: #{config.base_url}"
25
+ puts "Debug: #{config.debug?}"
26
+ puts "Credentials: #{File.exist?(config.credentials_path) ? "yes" : "no"}"
27
+ end
28
+
29
+ def list
30
+ config.environments.each do |name, env|
31
+ prefix = name == config.current_env ? "*" : " "
32
+ puts "#{prefix} #{name}\t#{env["url"]}"
33
+ end
34
+ end
35
+
36
+ def add(args = [])
37
+ options = {}
38
+ OptionParser.new do |opts|
39
+ opts.banner = "Usage: holivia env add <name> --url <url> [--debug]"
40
+ opts.on("--url URL") { |v| options[:url] = v }
41
+ opts.on("--debug") { options[:debug] = true }
42
+ opts.on("--no-debug") { options[:debug] = false }
43
+ end.parse!(args)
44
+
45
+ name = args.shift
46
+ abort "Usage: holivia env add <name> --url <url>" unless name && options[:url]
47
+
48
+ config.add_env(name, url: options[:url], debug: options[:debug])
49
+ puts "Environment '#{name}' added."
50
+ end
51
+
52
+ def use(args = [])
53
+ name = args.shift
54
+ abort "Usage: holivia env use <name>" unless name
55
+
56
+ config.use_env(name)
57
+ puts "Switched to '#{name}'."
58
+ end
59
+
60
+ def remove(args = [])
61
+ name = args.shift
62
+ abort "Usage: holivia env remove <name>" unless name
63
+
64
+ config.remove_env(name)
65
+ puts "Environment '#{name}' removed."
66
+ end
67
+
68
+ def debug(args = [])
69
+ value = nil
70
+ OptionParser.new do |opts|
71
+ opts.banner = "Usage: holivia env debug [name] --true/--false"
72
+ opts.on("--true") { value = true }
73
+ opts.on("--false") { value = false }
74
+ end.parse!(args)
75
+
76
+ name = args.shift || config.current_env
77
+ abort "Usage: holivia env debug [name] --true/--false" if value.nil?
78
+
79
+ config.update_env(name, debug: value)
80
+ puts "Debug #{value ? "on" : "off"} for '#{name}'."
81
+ end
82
+
83
+ private
84
+
85
+ def config
86
+ @config ||= Holivia.configuration
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Holivia
6
+ module Commands
7
+ class Format < Base
8
+ BASE_PATH = "/api/v1/backoffice/content_formats"
9
+
10
+ def self.route(args)
11
+ subcommand = args.shift
12
+ case subcommand
13
+ when "create" then new.create(args)
14
+ when "update" then new.update(args)
15
+ else warn "Unknown selfcare format command: #{subcommand}"
16
+ exit 1
17
+ end
18
+ end
19
+
20
+ def create(args = [])
21
+ options = {}
22
+ OptionParser.new do |opts|
23
+ opts.banner = "Usage: holivia selfcare format create [options]"
24
+ opts.on("--selfcare-content-id ID", Integer) { |v| options[:selfcare_content_id] = v }
25
+ opts.on("--format-type TYPE") { |v| options[:format_type] = v }
26
+ end.parse!(args)
27
+ options = options.merge(piped_json)
28
+
29
+ abort "No options provided. Use --help for usage." if options.empty?
30
+ output(client.post(BASE_PATH, body: options))
31
+ end
32
+
33
+ def update(args = [])
34
+ id = args.shift
35
+ abort "Usage: holivia selfcare format update <id> [options]" unless id
36
+
37
+ options = {}
38
+ OptionParser.new do |opts|
39
+ opts.banner = "Usage: holivia selfcare format update <id> [options]"
40
+ opts.on("--format-type TYPE") { |v| options[:format_type] = v }
41
+ end.parse!(args)
42
+ options = options.merge(piped_json)
43
+
44
+ abort "No options provided. Use --help for usage." if options.empty?
45
+ output(client.patch("#{BASE_PATH}/#{id}", body: options))
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Holivia
6
+ module Commands
7
+ class Item < Base
8
+ BASE_PATH = "/api/v1/backoffice/slide_items"
9
+ MIME_TYPES = {
10
+ ".mp3" => "audio/mpeg",
11
+ ".mp4" => "audio/mp4",
12
+ ".wav" => "audio/wav",
13
+ ".ogg" => "audio/ogg",
14
+ ".flac" => "audio/flac",
15
+ ".aac" => "audio/aac",
16
+ ".m4a" => "audio/mp4",
17
+ ".webm" => "audio/webm"
18
+ }.freeze
19
+
20
+ def self.route(args)
21
+ subcommand = args.shift
22
+ case subcommand
23
+ when "create" then new.create(args)
24
+ when "update" then new.update(args)
25
+ else warn "Unknown selfcare item command: #{subcommand}"
26
+ exit 1
27
+ end
28
+ end
29
+
30
+ def create(args = []) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
31
+ options = {}
32
+ OptionParser.new do |opts|
33
+ opts.banner = "Usage: holivia selfcare item create [options]"
34
+ opts.on("--slide-id ID", Integer) { |v| options[:slide_id] = v }
35
+ opts.on("--item-type TYPE") { |v| options[:item_type] = v }
36
+ # RichTextItem
37
+ opts.on("--content CONTENT") { |v| options[:content] = v }
38
+ # VideoItem
39
+ opts.on("--url URL") { |v| options[:url] = v }
40
+ # WistiaItem
41
+ opts.on("--code CODE") { |v| options[:code] = v }
42
+ # QuizItem
43
+ opts.on("--form-url URL") { |v| options[:form_url] = v }
44
+ opts.on("--score-threshold N", Integer) { |v| options[:score_threshold] = v }
45
+ opts.on("--total-score N", Integer) { |v| options[:total_score] = v }
46
+ # MeditationItem / BreathingItem
47
+ opts.on("--title TITLE") { |v| options[:title] = v }
48
+ opts.on("--duration DURATION", Integer) { |v| options[:duration] = v }
49
+ opts.on("--subtitle SUBTITLE") { |v| options[:subtitle] = v }
50
+ # BreathingItem
51
+ opts.on("--cycles N", Integer) { |v| options[:cycles] = v }
52
+ opts.on("--start-delay N", Integer) { |v| options[:start_delay] = v }
53
+ opts.on("--sequence SEQUENCE") { |v| options[:sequence] = JSON.parse(v) }
54
+ # Audio file upload
55
+ opts.on("--audio FILE") { |v| options[:audio] = v }
56
+ end.parse!(args)
57
+ options = options.merge(piped_json)
58
+
59
+ abort "No options provided. Use --help for usage." if options.empty?
60
+ output(client.post(BASE_PATH, body: build_body(options)))
61
+ end
62
+
63
+ def update(args = []) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
64
+ id = args.shift
65
+ abort "Usage: holivia selfcare item update <id> [options]" unless id
66
+
67
+ options = {}
68
+ OptionParser.new do |opts|
69
+ opts.banner = "Usage: holivia selfcare item update <id> [options]"
70
+ opts.on("--item-type TYPE") { |v| options[:item_type] = v }
71
+ opts.on("--content CONTENT") { |v| options[:content] = v }
72
+ opts.on("--url URL") { |v| options[:url] = v }
73
+ opts.on("--code CODE") { |v| options[:code] = v }
74
+ opts.on("--form-url URL") { |v| options[:form_url] = v }
75
+ opts.on("--score-threshold N", Integer) { |v| options[:score_threshold] = v }
76
+ opts.on("--total-score N", Integer) { |v| options[:total_score] = v }
77
+ opts.on("--title TITLE") { |v| options[:title] = v }
78
+ opts.on("--duration DURATION", Integer) { |v| options[:duration] = v }
79
+ opts.on("--subtitle SUBTITLE") { |v| options[:subtitle] = v }
80
+ opts.on("--cycles N", Integer) { |v| options[:cycles] = v }
81
+ opts.on("--start-delay N", Integer) { |v| options[:start_delay] = v }
82
+ opts.on("--sequence SEQUENCE") { |v| options[:sequence] = JSON.parse(v) }
83
+ opts.on("--audio FILE") { |v| options[:audio] = v }
84
+ end.parse!(args)
85
+ options = options.merge(piped_json)
86
+
87
+ abort "No options provided. Use --help for usage." if options.empty?
88
+ output(client.patch("#{BASE_PATH}/#{id}", body: build_body(options)))
89
+ end
90
+
91
+ private
92
+
93
+ def build_body(options)
94
+ audio_path = options.delete(:audio)
95
+ return options unless audio_path
96
+
97
+ mime = detect_mime(audio_path)
98
+ options.merge(audio: Faraday::Multipart::FilePart.new(audio_path, mime))
99
+ end
100
+
101
+ def detect_mime(path)
102
+ ext = File.extname(path).downcase
103
+ MIME_TYPES.fetch(ext, "application/octet-stream")
104
+ end
105
+ end
106
+ end
107
+ end
@@ -1,14 +1,97 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "base"
4
+
3
5
  module Holivia
4
6
  module Commands
5
- class Selfcare
7
+ class Selfcare < Base
6
8
  BASE_PATH = "/api/v1/backoffice/selfcare_contents"
9
+ COMPOSE_EXAMPLE_PATH = Holivia.root.join("examples", "compose.json").to_s
10
+
11
+ def self.route(args) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/AbcSize
12
+ subcommand = args.shift
13
+ case subcommand
14
+ when "index" then new.index(args)
15
+ when "show" then new.show(args)
16
+ when "create" then new.create(args)
17
+ when "update" then new.update(args)
18
+ when "compose" then new.compose(args)
19
+ when "schema" then new.schema(args)
20
+ when "format" then Format.route(args)
21
+ when "slide" then Slide.route(args)
22
+ when "item" then Item.route(args)
23
+ else warn "Unknown selfcare command: #{subcommand}"
24
+ exit 1
25
+ end
26
+ end
27
+
28
+ def index(_args = [])
29
+ output(client.get(BASE_PATH))
30
+ end
31
+
32
+ def show(args = [])
33
+ id = args.shift
34
+ abort "Usage: holivia selfcare show <id>" unless id
35
+
36
+ output(client.get("#{BASE_PATH}/#{id}"))
37
+ end
38
+
39
+ def create(args = []) # rubocop:disable Metrics/AbcSize
40
+ options = {}
41
+ OptionParser.new do |opts|
42
+ opts.banner = "Usage: holivia selfcare create [options]"
43
+ opts.on("--title TITLE") { |v| options[:title] = v }
44
+ opts.on("--locale LOCALE") { |v| options[:locale] = v }
45
+ opts.on("--content-type TYPE") { |v| options[:content_type] = v }
46
+ opts.on("--duration DURATION", Integer) { |v| options[:duration] = v }
47
+ opts.on("--description DESC") { |v| options[:description] = v }
48
+ end.parse!(args)
49
+ options = options.merge(piped_json)
50
+
51
+ abort "No options provided. Use --help for usage." if options.empty?
52
+ output(client.post(BASE_PATH, body: options))
53
+ end
54
+
55
+ def update(args = []) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
56
+ id = args.shift
57
+ abort "Usage: holivia selfcare update <id> [options]" unless id
58
+
59
+ options = {}
60
+ OptionParser.new do |opts|
61
+ opts.banner = "Usage: holivia selfcare update <id> [options]"
62
+ opts.on("--title TITLE") { |v| options[:title] = v }
63
+ opts.on("--locale LOCALE") { |v| options[:locale] = v }
64
+ opts.on("--content-type TYPE") { |v| options[:content_type] = v }
65
+ opts.on("--duration DURATION", Integer) { |v| options[:duration] = v }
66
+ opts.on("--description DESC") { |v| options[:description] = v }
67
+ end.parse!(args)
68
+ options = options.merge(piped_json)
69
+
70
+ abort "No options provided. Use --help for usage." if options.empty?
71
+ output(client.patch("#{BASE_PATH}/#{id}", body: options))
72
+ end
73
+
74
+ def compose(args = []) # rubocop:disable Metrics/MethodLength
75
+ show_example = false
76
+ options = {}
77
+ OptionParser.new do |opts|
78
+ opts.banner = "Usage: holivia selfcare compose --file <path>"
79
+ opts.on("--file FILE") { |v| options = JSON.parse(File.read(v), symbolize_names: true) }
80
+ opts.on("--example", "Print a valid compose JSON template") { show_example = true }
81
+ end.parse!(args)
82
+
83
+ if show_example
84
+ puts File.read(COMPOSE_EXAMPLE_PATH)
85
+ return
86
+ end
87
+
88
+ options = options.merge(piped_json)
89
+ abort "Usage: holivia selfcare compose --file <path>" if options.empty?
90
+ output(client.post("#{BASE_PATH}/compose", body: options))
91
+ end
7
92
 
8
- def index
9
- client = Client.new
10
- response = client.get(BASE_PATH)
11
- puts JSON.pretty_generate(response)
93
+ def schema(_args = [])
94
+ output(client.get("#{BASE_PATH}/schema"))
12
95
  end
13
96
  end
14
97
  end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Holivia
6
+ module Commands
7
+ class Slide < Base
8
+ BASE_PATH = "/api/v1/backoffice/slides"
9
+
10
+ def self.route(args)
11
+ subcommand = args.shift
12
+ case subcommand
13
+ when "create" then new.create(args)
14
+ when "update" then new.update(args)
15
+ else warn "Unknown selfcare slide command: #{subcommand}"
16
+ exit 1
17
+ end
18
+ end
19
+
20
+ def create(args = [])
21
+ options = {}
22
+ OptionParser.new do |opts|
23
+ opts.banner = "Usage: holivia selfcare slide create [options]"
24
+ opts.on("--content-format-id ID", Integer) { |v| options[:content_format_id] = v }
25
+ opts.on("--title TITLE") { |v| options[:title] = v }
26
+ opts.on("--duration DURATION", Integer) { |v| options[:duration] = v }
27
+ end.parse!(args)
28
+ options = options.merge(piped_json)
29
+
30
+ abort "No options provided. Use --help for usage." if options.empty?
31
+ output(client.post(BASE_PATH, body: options))
32
+ end
33
+
34
+ def update(args = [])
35
+ id = args.shift
36
+ abort "Usage: holivia selfcare slide update <id> [options]" unless id
37
+
38
+ options = {}
39
+ OptionParser.new do |opts|
40
+ opts.banner = "Usage: holivia selfcare slide update <id> [options]"
41
+ opts.on("--title TITLE") { |v| options[:title] = v }
42
+ opts.on("--duration DURATION", Integer) { |v| options[:duration] = v }
43
+ end.parse!(args)
44
+ options = options.merge(piped_json)
45
+
46
+ abort "No options provided. Use --help for usage." if options.empty?
47
+ output(client.patch("#{BASE_PATH}/#{id}", body: options))
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Holivia
4
+ class ConfigError < StandardError; end
5
+ end
@@ -1,13 +1,71 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "yaml"
4
+ require_relative "env_manager"
5
+
3
6
  module Holivia
4
7
  class Configuration
5
- attr_accessor :base_url, :credentials_path, :debug
8
+ include EnvManager
9
+
10
+ CONFIG_DIR = File.expand_path("~/.holivia")
11
+ DEFAULT_ENV = "staging"
12
+ DEFAULT_URL = "https://staging.holivia.fr"
13
+
14
+ attr_reader :config_dir
15
+
16
+ def initialize(config_dir: CONFIG_DIR)
17
+ @config_dir = config_dir
18
+ @config = load_config
19
+ end
20
+
21
+ def current_env
22
+ @config["current_env"]
23
+ end
24
+
25
+ def environments
26
+ @config["environments"]
27
+ end
28
+
29
+ def base_url
30
+ environments.dig(current_env, "url")
31
+ end
32
+
33
+ def credentials_path
34
+ File.join(config_dir, "credentials.#{current_env}.json")
35
+ end
36
+
37
+ def debug?
38
+ environments.dig(current_env, "debug") == true
39
+ end
40
+
41
+ private
42
+
43
+ attr_reader :config
44
+
45
+ def config_path
46
+ File.join(config_dir, "config.yml")
47
+ end
48
+
49
+ def load_config
50
+ raw = YAML.safe_load_file(config_path)
51
+ validated_config(raw)
52
+ rescue Errno::ENOENT
53
+ default_config
54
+ end
55
+
56
+ def validated_config(raw)
57
+ return default_config unless raw.is_a?(Hash)
58
+ return default_config unless raw["environments"].is_a?(Hash)
59
+ return default_config unless raw["current_env"].is_a?(String)
60
+
61
+ raw
62
+ end
6
63
 
7
- def initialize
8
- @base_url = ENV.fetch("HOLIVIA_API_URL", "http://localhost:3000")
9
- @credentials_path = File.expand_path("~/.holivia/credentials.json")
10
- @debug = ENV["HOLIVIA_DEBUG"] == "true"
64
+ def default_config
65
+ {
66
+ "current_env" => DEFAULT_ENV,
67
+ "environments" => { DEFAULT_ENV => { "url" => DEFAULT_URL } }
68
+ }
11
69
  end
12
70
  end
13
71
  end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "fileutils"
5
+
6
+ module Holivia
7
+ module EnvManager
8
+ def add_env(name, url:, debug: nil)
9
+ env = { "url" => url }
10
+ env["debug"] = debug unless debug.nil?
11
+ config["environments"][name] = env
12
+ config["current_env"] = name if config["environments"].size == 1
13
+ save_config
14
+ end
15
+
16
+ def use_env(name)
17
+ raise ConfigError, "Unknown environment '#{name}'" unless config["environments"].key?(name)
18
+
19
+ config["current_env"] = name
20
+ save_config
21
+ end
22
+
23
+ def update_env(name, **attrs)
24
+ raise ConfigError, "Unknown environment '#{name}'" unless config["environments"].key?(name)
25
+
26
+ attrs.each { |k, v| config["environments"][name][k.to_s] = v }
27
+ save_config
28
+ end
29
+
30
+ def remove_env(name)
31
+ raise ConfigError, "Cannot remove '#{name}' — it is the only environment" if config["environments"].size == 1
32
+
33
+ config["environments"].delete(name)
34
+ config["current_env"] = config["environments"].keys.first if name == current_env
35
+ FileUtils.rm_f(File.join(config_dir, "credentials.#{name}.json"))
36
+ save_config
37
+ end
38
+
39
+ private
40
+
41
+ def save_config
42
+ FileUtils.mkdir_p(config_dir)
43
+ File.write(config_path, YAML.dump(config))
44
+ end
45
+ end
46
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Holivia
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/holivia.rb CHANGED
@@ -1,8 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "pathname"
3
4
  require_relative "holivia/version"
4
5
  require_relative "holivia/configuration"
5
6
  require_relative "holivia/api_error"
7
+ require_relative "holivia/config_error"
6
8
  require_relative "holivia/client"
7
9
  require_relative "holivia/auth"
8
10
 
@@ -10,12 +12,12 @@ module Holivia
10
12
  class << self
11
13
  attr_writer :configuration
12
14
 
13
- def configuration
14
- @configuration ||= Configuration.new
15
+ def root
16
+ Pathname.new(File.expand_path("..", __dir__))
15
17
  end
16
18
 
17
- def configure
18
- yield(configuration)
19
+ def configuration
20
+ @configuration ||= Configuration.new
19
21
  end
20
22
  end
21
23
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: holivia
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Holivia
@@ -23,6 +23,20 @@ dependencies:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
25
  version: '2.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: faraday-multipart
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '1.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '1.0'
26
40
  description: API client library and command-line tool for the Holivia platform
27
41
  email:
28
42
  - dev@holivia.fr
@@ -38,15 +52,25 @@ files:
38
52
  - LICENSE.txt
39
53
  - README.md
40
54
  - Rakefile
55
+ - example_content.xml
56
+ - examples/compose.json
41
57
  - exe/holivia
42
58
  - lib/holivia.rb
43
59
  - lib/holivia/api_error.rb
44
60
  - lib/holivia/auth.rb
45
61
  - lib/holivia/cli.rb
62
+ - lib/holivia/cli/help.rb
46
63
  - lib/holivia/client.rb
47
64
  - lib/holivia/commands/auth.rb
65
+ - lib/holivia/commands/base.rb
66
+ - lib/holivia/commands/env.rb
67
+ - lib/holivia/commands/format.rb
68
+ - lib/holivia/commands/item.rb
48
69
  - lib/holivia/commands/selfcare.rb
70
+ - lib/holivia/commands/slide.rb
71
+ - lib/holivia/config_error.rb
49
72
  - lib/holivia/configuration.rb
73
+ - lib/holivia/env_manager.rb
50
74
  - lib/holivia/version.rb
51
75
  - mise.toml
52
76
  - sig/holivia.rbs